From 07cacf51803bdad12f5a12ad4cb42aa02a190089 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= <103562092+MarvinOehlerkingCap@users.noreply.github.com> Date: Mon, 26 Aug 2024 14:13:48 +0200 Subject: [PATCH] N21-2133 Authentication (#10) --- ansible/group_vars/develop/shd-client.yml | 2 +- .../roles/shd-client-core/defaults/main.yml | 2 +- .../shd-client-core/templates/ingress.yml.j2 | 6 - config/webpack/webpack.dev.js | 1 + package-lock.json | 325 ++++++++---------- package.json | 8 +- src/App.vue | 14 +- src/locales/de.ts | 7 +- src/locales/en.ts | 7 +- src/locales/es.ts | 7 +- src/locales/uk.ts | 7 +- src/main.ts | 11 +- .../application-error/applicationError.ts | 30 ++ .../applicationError.unit.ts | 126 +++++++ src/modules/data/auth/auth.store.ts | 61 +++- src/modules/data/auth/auth.store.unit.ts | 205 ++++++++++- src/modules/data/auth/index.ts | 1 + src/modules/data/auth/jwtCookie.composable.ts | 41 +++ .../data/auth/jwtCookie.composable.unit.ts | 121 +++++++ src/modules/feature/logout/LogoutBtn.unit.ts | 103 ++++++ src/modules/feature/logout/LogoutBtn.vue | 24 ++ src/modules/feature/logout/index.ts | 3 + src/modules/page/AboutView.unit.ts | 25 -- src/modules/page/AboutView.vue | 12 - src/modules/page/HomeView.vue | 4 +- src/modules/page/Login.page.unit.ts | 182 ++++++++++ src/modules/page/Login.page.vue | 98 ++++++ src/plugins/applicationErrorHandler.ts | 3 +- src/router/guards/is-authenticated.guard.ts | 38 +- src/router/guards/is-authenticated.unit.ts | 114 +++--- src/router/guards/login-redirect-url.ts | 37 -- src/router/guards/login-redirect-url.unit.ts | 87 ----- src/router/index.ts | 4 +- src/router/postLoginRoute.ts | 1 + src/router/routes.ts | 9 +- src/serverApi/v3/.openapi-generator/FILES | 3 +- src/serverApi/v3/api/courses-api.ts | 83 +++++ src/serverApi/v3/api/oauth2-api.ts | 10 +- ...urse-common-cartridge-metadata-response.ts | 49 +++ .../v3/models/import-user-response.ts | 6 + src/serverApi/v3/models/index.ts | 2 + src/serverApi/v3/models/login-response.ts | 58 +--- .../models/oauth-provider-login-response.ts | 87 +++++ src/styles/main.scss | 2 +- src/styles/typography.scss | 32 ++ src/utils/api/api.ts | 8 +- tests/test-utils/factory/index.ts | 2 + .../factory/loginResponseFactory.ts | 8 + tests/test-utils/factory/router/index.ts | 1 + .../router/routeLocationNormalizedFactory.ts | 20 ++ 50 files changed, 1567 insertions(+), 530 deletions(-) create mode 100644 src/modules/data/application-error/applicationError.unit.ts create mode 100644 src/modules/data/auth/jwtCookie.composable.ts create mode 100644 src/modules/data/auth/jwtCookie.composable.unit.ts create mode 100644 src/modules/feature/logout/LogoutBtn.unit.ts create mode 100644 src/modules/feature/logout/LogoutBtn.vue create mode 100644 src/modules/feature/logout/index.ts delete mode 100644 src/modules/page/AboutView.unit.ts delete mode 100644 src/modules/page/AboutView.vue create mode 100644 src/modules/page/Login.page.unit.ts create mode 100644 src/modules/page/Login.page.vue delete mode 100644 src/router/guards/login-redirect-url.ts delete mode 100644 src/router/guards/login-redirect-url.unit.ts create mode 100644 src/router/postLoginRoute.ts create mode 100644 src/serverApi/v3/models/course-common-cartridge-metadata-response.ts create mode 100644 src/serverApi/v3/models/oauth-provider-login-response.ts create mode 100644 tests/test-utils/factory/loginResponseFactory.ts create mode 100644 tests/test-utils/factory/router/index.ts create mode 100644 tests/test-utils/factory/router/routeLocationNormalizedFactory.ts diff --git a/ansible/group_vars/develop/shd-client.yml b/ansible/group_vars/develop/shd-client.yml index 434fda7..a5e2fb9 100644 --- a/ansible/group_vars/develop/shd-client.yml +++ b/ansible/group_vars/develop/shd-client.yml @@ -1,3 +1,3 @@ --- SHD_CLIENT_IMAGE: ghcr.io/hpi-schul-cloud/shd-client -SHD_CLIENT_PREFIX: shd2- \ No newline at end of file +SHD_CLIENT_PREFIX: superhero- \ No newline at end of file diff --git a/ansible/roles/shd-client-core/defaults/main.yml b/ansible/roles/shd-client-core/defaults/main.yml index 98b2ed5..a1ef41b 100644 --- a/ansible/roles/shd-client-core/defaults/main.yml +++ b/ansible/roles/shd-client-core/defaults/main.yml @@ -1,3 +1,3 @@ --- SHD_CLIENT_IMAGE: quay.io/schulcloudverbund/shd-client -SHD_CLIENT_PREFIX: dashboard2. +SHD_CLIENT_PREFIX: superhero. diff --git a/ansible/roles/shd-client-core/templates/ingress.yml.j2 b/ansible/roles/shd-client-core/templates/ingress.yml.j2 index d27dab5..b273477 100644 --- a/ansible/roles/shd-client-core/templates/ingress.yml.j2 +++ b/ansible/roles/shd-client-core/templates/ingress.yml.j2 @@ -6,12 +6,6 @@ metadata: namespace: {{ NAMESPACE }} annotations: nginx.ingress.kubernetes.io/ssl-redirect: "{{ TLS_ENABLED|default("false") }}" - # type of authentication - nginx.ingress.kubernetes.io/auth-type: basic - # name of the secret that contains the user/password definitions - nginx.ingress.kubernetes.io/auth-secret: shd-basic-auth-secret - # message to display with an appropriate context why the authentication is required - nginx.ingress.kubernetes.io/auth-realm: 'Authentication Required' {% if CLUSTER_ISSUER is defined %} cert-manager.io/cluster-issuer: {{ CLUSTER_ISSUER }} {% endif %} diff --git a/config/webpack/webpack.dev.js b/config/webpack/webpack.dev.js index 9c04ed8..17973f2 100644 --- a/config/webpack/webpack.dev.js +++ b/config/webpack/webpack.dev.js @@ -17,6 +17,7 @@ module.exports = merge(common, { process.env.NODE_ENV === "development" ? { port: 4100, + historyApiFallback: true, allowedHosts: "all", client: { overlay: false, diff --git a/package-lock.json b/package-lock.json index 749db56..d1d7cf3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "dependencies": { "@vueuse/core": "^10.11.0", "axios": "^1.7.2", + "cross-env": "^7.0.3", "dayjs": "^1.11.12", "pinia": "^2.2.0", "ts-node": "^10.9.2", @@ -2061,9 +2062,9 @@ } }, "node_modules/@golevelup/ts-jest": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/@golevelup/ts-jest/-/ts-jest-0.5.1.tgz", - "integrity": "sha512-1DfWJ4d2X3Ik7wKH2+WOtVHoYgdQAhgptoTRJOgJm18f02eKEnDyppxL7SREM3dbLx+jlFma+RcM+e5dl2Dymg==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@golevelup/ts-jest/-/ts-jest-0.5.3.tgz", + "integrity": "sha512-PoGR1S8qw2N05yT0wymYZhBQL/UdapX2qDHHflrIvX4quaXthPNR3S9vuhvwNxq2Ike1GKCsPJdDJ0zQxGYiWw==", "dev": true, "license": "MIT" }, @@ -3912,12 +3913,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.2.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.2.0.tgz", - "integrity": "sha512-bm6EG6/pCpkxDf/0gDNDdtDILMOHgaQBVOJGdwsqClnxA3xL6jtMv76rLBc006RVMWbmaf0xbmom4Z/5o2nRkQ==", + "version": "22.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.3.0.tgz", + "integrity": "sha512-nrWpWVaDZuaVc5X84xJ0vNrLvomM205oQyLsRt7OHNZbSHslcWsvgFR7O7hire2ZonjLrWBbedmotmIlJDVd6g==", "license": "MIT", "dependencies": { - "undici-types": "~6.13.0" + "undici-types": "~6.18.2" } }, "node_modules/@types/node-forge": { @@ -4276,39 +4277,39 @@ "license": "ISC" }, "node_modules/@vue/compiler-core": { - "version": "3.4.37", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.37.tgz", - "integrity": "sha512-ZDDT/KiLKuCRXyzWecNzC5vTcubGz4LECAtfGPENpo0nrmqJHwuWtRLxk/Sb9RAKtR9iFflFycbkjkY+W/PZUQ==", + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.38.tgz", + "integrity": "sha512-8IQOTCWnLFqfHzOGm9+P8OPSEDukgg3Huc92qSG49if/xI2SAwLHQO2qaPQbjCWPBcQoO1WYfXfTACUrWV3c5A==", "license": "MIT", "dependencies": { "@babel/parser": "^7.24.7", - "@vue/shared": "3.4.37", - "entities": "^5.0.0", + "@vue/shared": "3.4.38", + "entities": "^4.5.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.0" } }, "node_modules/@vue/compiler-dom": { - "version": "3.4.37", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.37.tgz", - "integrity": "sha512-rIiSmL3YrntvgYV84rekAtU/xfogMUJIclUMeIKEtVBFngOL3IeZHhsH3UaFEgB5iFGpj6IW+8YuM/2Up+vVag==", + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.38.tgz", + "integrity": "sha512-Osc/c7ABsHXTsETLgykcOwIxFktHfGSUDkb05V61rocEfsFDcjDLH/IHJSNJP+/Sv9KeN2Lx1V6McZzlSb9EhQ==", "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.4.37", - "@vue/shared": "3.4.37" + "@vue/compiler-core": "3.4.38", + "@vue/shared": "3.4.38" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.4.37", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.37.tgz", - "integrity": "sha512-vCfetdas40Wk9aK/WWf8XcVESffsbNkBQwS5t13Y/PcfqKfIwJX2gF+82th6dOpnpbptNMlMjAny80li7TaCIg==", + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.38.tgz", + "integrity": "sha512-s5QfZ+9PzPh3T5H4hsQDJtI8x7zdJaew/dCGgqZ2630XdzaZ3AD8xGZfBqpT8oaD/p2eedd+pL8tD5vvt5ZYJQ==", "license": "MIT", "dependencies": { "@babel/parser": "^7.24.7", - "@vue/compiler-core": "3.4.37", - "@vue/compiler-dom": "3.4.37", - "@vue/compiler-ssr": "3.4.37", - "@vue/shared": "3.4.37", + "@vue/compiler-core": "3.4.38", + "@vue/compiler-dom": "3.4.38", + "@vue/compiler-ssr": "3.4.38", + "@vue/shared": "3.4.38", "estree-walker": "^2.0.2", "magic-string": "^0.30.10", "postcss": "^8.4.40", @@ -4316,13 +4317,13 @@ } }, "node_modules/@vue/compiler-ssr": { - "version": "3.4.37", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.37.tgz", - "integrity": "sha512-TyAgYBWrHlFrt4qpdACh8e9Ms6C/AZQ6A6xLJaWrCL8GCX5DxMzxyeFAEMfU/VFr4tylHm+a2NpfJpcd7+20XA==", + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.38.tgz", + "integrity": "sha512-YXznKFQ8dxYpAz9zLuVvfcXhc31FSPFDcqr0kyujbOwNhlmaNvL2QfIy+RZeJgSn5Fk54CWoEUeW+NVBAogGaw==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.4.37", - "@vue/shared": "3.4.37" + "@vue/compiler-dom": "3.4.38", + "@vue/shared": "3.4.38" } }, "node_modules/@vue/devtools-api": { @@ -4357,53 +4358,53 @@ } }, "node_modules/@vue/reactivity": { - "version": "3.4.37", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.37.tgz", - "integrity": "sha512-UmdKXGx0BZ5kkxPqQr3PK3tElz6adTey4307NzZ3whZu19i5VavYal7u2FfOmAzlcDVgE8+X0HZ2LxLb/jgbYw==", + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.38.tgz", + "integrity": "sha512-4vl4wMMVniLsSYYeldAKzbk72+D3hUnkw9z8lDeJacTxAkXeDAP1uE9xr2+aKIN0ipOL8EG2GPouVTH6yF7Gnw==", "license": "MIT", "dependencies": { - "@vue/shared": "3.4.37" + "@vue/shared": "3.4.38" } }, "node_modules/@vue/runtime-core": { - "version": "3.4.37", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.37.tgz", - "integrity": "sha512-MNjrVoLV/sirHZoD7QAilU1Ifs7m/KJv4/84QVbE6nyAZGQNVOa1HGxaOzp9YqCG+GpLt1hNDC4RbH+KtanV7w==", + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.38.tgz", + "integrity": "sha512-21z3wA99EABtuf+O3IhdxP0iHgkBs1vuoCAsCKLVJPEjpVqvblwBnTj42vzHRlWDCyxu9ptDm7sI2ZMcWrQqlA==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.4.37", - "@vue/shared": "3.4.37" + "@vue/reactivity": "3.4.38", + "@vue/shared": "3.4.38" } }, "node_modules/@vue/runtime-dom": { - "version": "3.4.37", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.37.tgz", - "integrity": "sha512-Mg2EwgGZqtwKrqdL/FKMF2NEaOHuH+Ks9TQn3DHKyX//hQTYOun+7Tqp1eo0P4Ds+SjltZshOSRq6VsU0baaNg==", + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.38.tgz", + "integrity": "sha512-afZzmUreU7vKwKsV17H1NDThEEmdYI+GCAK/KY1U957Ig2NATPVjCROv61R19fjZNzMmiU03n79OMnXyJVN0UA==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.4.37", - "@vue/runtime-core": "3.4.37", - "@vue/shared": "3.4.37", + "@vue/reactivity": "3.4.38", + "@vue/runtime-core": "3.4.38", + "@vue/shared": "3.4.38", "csstype": "^3.1.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.4.37", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.37.tgz", - "integrity": "sha512-jZ5FAHDR2KBq2FsRUJW6GKDOAG9lUTX8aBEGq4Vf6B/35I9fPce66BornuwmqmKgfiSlecwuOb6oeoamYMohkg==", + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.38.tgz", + "integrity": "sha512-NggOTr82FbPEkkUvBm4fTGcwUY8UuTsnWC/L2YZBmvaQ4C4Jl/Ao4HHTB+l7WnFCt5M/dN3l0XLuyjzswGYVCA==", "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.4.37", - "@vue/shared": "3.4.37" + "@vue/compiler-ssr": "3.4.38", + "@vue/shared": "3.4.38" }, "peerDependencies": { - "vue": "3.4.37" + "vue": "3.4.38" } }, "node_modules/@vue/shared": { - "version": "3.4.37", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.37.tgz", - "integrity": "sha512-nIh8P2fc3DflG8+5Uw8PT/1i17ccFn0xxN/5oE9RfV5SVnd7G0XEFRwakrnNFE/jlS95fpGXDVG5zDETS26nmg==", + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.38.tgz", + "integrity": "sha512-q0xCiLkuWWQLzVrecPb0RMsNWyxICOjPrcrwxTUEHb1fsnvni4dcuyG7RT/Ie7VPTvnjzIaWzRMUBsrqNj/hhw==", "license": "MIT" }, "node_modules/@vue/test-utils": { @@ -5061,9 +5062,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.3.tgz", - "integrity": "sha512-Ar7ND9pU99eJ9GpoGQKhKf58GpUOgnzuaB7ueNQ5BMi0p+LZ5oaEnfF999fAArcTIBwXTCHAmGcHOZJaWPq9Nw==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", + "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -5263,24 +5264,27 @@ } }, "node_modules/babel-preset-current-node-syntax": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", - "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz", + "integrity": "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==", "dev": true, "license": "MIT", "dependencies": { "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.8.3", - "@babel/plugin-syntax-import-meta": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-top-level-await": "^7.8.3" + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0" @@ -5835,60 +5839,6 @@ "wrap-ansi": "^7.0.0" } }, - "node_modules/cliui/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/cliui/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/cliui/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/clone": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", @@ -6481,11 +6431,28 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "license": "MIT" }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -7141,9 +7108,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.6", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.6.tgz", - "integrity": "sha512-jwXWsM5RPf6j9dPYzaorcBSUg6AiqocPEyMpkchkvntaH9HGfOOMZwxMJjDY/XEs3T5dM7uyH1VhRMkqUU9qVw==", + "version": "1.5.8", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.8.tgz", + "integrity": "sha512-4Nx0gP2tPNBLTrFxBMHpkQbtn2hidPVr/+/FTtcCiBYTucqc70zRyVZiOLj17Ui3wTO7SQ1/N+hkHYzJjBzt6A==", "devOptional": true, "license": "ISC" }, @@ -7202,9 +7169,9 @@ } }, "node_modules/entities": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-5.0.0.tgz", - "integrity": "sha512-BeJFvFRJddxobhvEdm5GqHzRV/X+ACeuw0/BuuxsCh1EUZcAIz8+kYmBp/LrQuloy6K1f3a0M7+IhmZ7QnkISA==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -9312,6 +9279,21 @@ "node": ">=8" } }, + "node_modules/inquirer/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/interpret": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", @@ -9575,7 +9557,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/isobject": { @@ -10112,24 +10093,6 @@ "node": ">=8" } }, - "node_modules/jest-cli/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/jest-cli/node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -12980,19 +12943,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/parse5/node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -13038,7 +12988,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -13388,9 +13337,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.1.tgz", - "integrity": "sha512-b4dlw/9V8A71rLIDsSwVmak9z2DuBUB7CA1/wSdelNEzqsjoSPeADTWNO09lpH49Diy3/JIZ2bSPB1dI3LJCHg==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "dev": true, "license": "MIT", "dependencies": { @@ -14402,7 +14351,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -14415,7 +14363,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -14780,9 +14727,9 @@ } }, "node_modules/terser": { - "version": "5.31.5", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.31.5.tgz", - "integrity": "sha512-YPmas0L0rE1UyLL/llTWA0SiDOqIcAQYLeUj7cJYzXHlRTAnMSg9pPe4VJ5PlKvTrPQsdVFuiRiwyeNlYgwh2Q==", + "version": "5.31.6", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.31.6.tgz", + "integrity": "sha512-PQ4DAriWzKj+qgehQ7LK5bQqCFNMmlhjR2PFFLuqGCpuCAauxemVBWwWOxo3UIwWQx8+Pr61Df++r76wDmkQBg==", "devOptional": true, "license": "BSD-2-Clause", "dependencies": { @@ -15480,9 +15427,9 @@ } }, "node_modules/undici-types": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.13.0.tgz", - "integrity": "sha512-xtFJHudx8S2DSoujjMd1WeWvn7KKWFRESZTMeL1RptAYERu29D6jphMjjY+vn96jvN3kVPDNxU/E13VTaXj6jg==", + "version": "6.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.18.2.tgz", + "integrity": "sha512-5ruQbENj95yDYJNS3TvcaxPMshV7aizdv/hWYjGIKoANWKjhWNBsr2YEuYZKodQulB1b8l7ILOuDQep3afowQQ==", "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -15701,16 +15648,16 @@ } }, "node_modules/vue": { - "version": "3.4.37", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.37.tgz", - "integrity": "sha512-3vXvNfkKTBsSJ7JP+LyR7GBuwQuckbWvuwAid3xbqK9ppsKt/DUvfqgZ48fgOLEfpy1IacL5f8QhUVl77RaI7A==", + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.38.tgz", + "integrity": "sha512-f0ZgN+mZ5KFgVv9wz0f4OgVKukoXtS3nwET4c2vLBGQR50aI8G0cqbFtLlX9Yiyg3LFGBitruPHt2PxwTduJEw==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.4.37", - "@vue/compiler-sfc": "3.4.37", - "@vue/runtime-dom": "3.4.37", - "@vue/server-renderer": "3.4.37", - "@vue/shared": "3.4.37" + "@vue/compiler-dom": "3.4.38", + "@vue/compiler-sfc": "3.4.38", + "@vue/runtime-dom": "3.4.38", + "@vue/server-renderer": "3.4.38", + "@vue/shared": "3.4.38" }, "peerDependencies": { "typescript": "*" @@ -15931,9 +15878,9 @@ "license": "MIT" }, "node_modules/vuetify": { - "version": "3.6.14", - "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.6.14.tgz", - "integrity": "sha512-iSa3CgdTEt/7B0aGDmkBARe8rxDDycEYHu1zNtOf1Xpvs/Tv7Ql5yHGqM2XCY0h7SL8Dme39pJIovzg3q4JLbQ==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.7.0.tgz", + "integrity": "sha512-x+UaU4SPYNcJSE/voCTBFrNn0q9Spzx2EMfDdUj0NYgHGKb59OqnZte+AjaJaoOXy1AHYIGEpm5Ryk2BEfgWuw==", "license": "MIT", "engines": { "node": "^12.20 || >=14.13" @@ -15988,9 +15935,9 @@ } }, "node_modules/watchpack": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz", - "integrity": "sha512-8wrBCMtVhqcXP2Sup1ctSkga6uc2Bx0IIvKyT7yTFier5AXHooSI+QyQQAtTb7+E0IUCCKyTFmXqdqgum2XWGg==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", + "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -16467,7 +16414,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -16497,9 +16443,9 @@ } }, "node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", "dependencies": { @@ -16508,7 +16454,10 @@ "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/wrap-ansi-cjs": { diff --git a/package.json b/package.json index 96e1826..07c5015 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,9 @@ "jest" ], "scripts": { - "serve": "NODE_ENV=development webpack serve --config config/webpack/webpack.dev.js", - "serve:windows": "set NODE_ENV=development&& webpack serve --config config/webpack/webpack.dev.js", - "build": "NODE_ENV=production webpack --config config/webpack/webpack.prod.js", - "test": "npm run test:unit", + "serve": "cross-env NODE_ENV=development webpack serve --config config/webpack/webpack.dev.js", + "build": "cross-env NODE_ENV=production webpack --config config/webpack/webpack.prod.js", + "test": "cross-env NODE_ENV=test npm run test:unit", "test:unit": "npx jest", "test:unit:ci": "npm run test:unit -- --coverage --ci --maxWorkers=4", "lint": "npx eslint 'src/**/*.{ts,js,vue}'", @@ -19,6 +18,7 @@ "dependencies": { "@vueuse/core": "^10.11.0", "axios": "^1.7.2", + "cross-env": "^7.0.3", "dayjs": "^1.11.12", "pinia": "^2.2.0", "ts-node": "^10.9.2", diff --git a/src/App.vue b/src/App.vue index 96f450a..8f6105b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -8,22 +8,28 @@

New Superhero-Dashboard

+ + + -
- -
+
+
+ +
+
diff --git a/src/locales/de.ts b/src/locales/de.ts index 455453b..0a7e4c2 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -1,4 +1,9 @@ export default { + "common.actions.login": "Anmelden", + "common.actions.logout": "Abmelden", + "common.words.emailAddress": "E-Mail Adresse", + "common.words.password": "Passwort", "error.generic": "Ein Fehler ist aufgetreten", - "error.load": "Fehler beim Laden der Daten.", + "error.load": "Fehler beim Laden der Daten", + "error.login.noSuperhero": "Der ausgewählte Account ist kein Superhero", }; diff --git a/src/locales/en.ts b/src/locales/en.ts index 2dbe1d4..a7e486a 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -1,4 +1,9 @@ export default { + "common.actions.login": "Log in", + "common.actions.logout": "Log out", + "common.words.emailAddress": "E-Mail Address", + "common.words.password": "Password", "error.generic": "An error has occurred", - "error.load": "Error while loading the data.", + "error.load": "Error while loading the data", + "error.login.noSuperhero": "The selected account is not a superhero", }; diff --git a/src/locales/es.ts b/src/locales/es.ts index 2e52cb3..06f97a9 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -1,4 +1,9 @@ export default { + "common.actions.login": "Conectarse", + "common.actions.logout": "Cerrar sesión", + "common.words.emailAddress": "Correo electrónico", + "common.words.password": "Contraseña", "error.generic": "Se ha producido un error", - "error.load": "Error al cargar los datos.", + "error.load": "Error al cargar los datos", + "error.login.noSuperhero": "La cuenta seleccionada no es un superhéroe", }; diff --git a/src/locales/uk.ts b/src/locales/uk.ts index 2fa7655..5be33f4 100644 --- a/src/locales/uk.ts +++ b/src/locales/uk.ts @@ -1,4 +1,9 @@ export default { + "common.actions.login": "зареєструватися", + "common.actions.logout": "Вийти", + "common.words.emailAddress": "адреса електронної пошти", + "common.words.password": "пароль", "error.generic": "Виникла помилка", - "error.load": "Помилка під час завантаження даних.", + "error.load": "Помилка під час завантаження даних", + "error.login.noSuperhero": "Вибраний обліковий запис не є супергероєм", }; diff --git a/src/main.ts b/src/main.ts index cb23d0e..d1b9daf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,12 +4,11 @@ import { createI18n } from "@/plugins/i18n"; import vuetify from "@/plugins/vuetify"; import router from "@/router"; import { initializeAxios } from "@/utils/api"; -import { useAuthStore } from "@data/auth"; +import { useAuthStore, useJwtCookie } from "@data/auth"; import { useEnvConfigStore } from "@data/env-config"; import { htmlConfig } from "@feature/render-html"; import axios from "axios"; import { createPinia } from "pinia"; -import Cookies from "universal-cookie"; import { createApp } from "vue"; import VueDOMPurifyHTML from "vue-dompurify-html"; import App from "./App.vue"; @@ -38,13 +37,15 @@ app.use(VueDOMPurifyHTML, { await useEnvConfigStore().loadConfig(); - const cookies = new Cookies(); - const jwt = cookies.get("jwt"); + const authStore = useAuthStore(); + const { getJwt } = useJwtCookie(); + const jwt = getJwt(); if (jwt) { axios.defaults.headers.common["Authorization"] = "Bearer " + jwt; + try { - await useAuthStore().login(); + await authStore.fetchMe(); } catch (e) { // eslint-disable-next-line no-console console.error("### JWT invalid: ", e); diff --git a/src/modules/data/application-error/applicationError.ts b/src/modules/data/application-error/applicationError.ts index 9be2940..967ed3d 100644 --- a/src/modules/data/application-error/applicationError.ts +++ b/src/modules/data/application-error/applicationError.ts @@ -1,3 +1,8 @@ +import { + ApiResponseError, + ApiValidationError, + mapAxiosErrorToResponseError, +} from "@/utils/api"; import { HttpStatusCode } from "./httpStatusCode.enum"; export interface ApplicationErrorProps { @@ -18,4 +23,29 @@ export class ApplicationError extends Error { this.statusCode = props.statusCode; this.translationKey = props.translationKey; } + + static fromUnknown( + error: unknown, + translationKey?: string + ): ApplicationError { + if (error instanceof ApplicationError) { + return error; + } + + const apiError: ApiResponseError | ApiValidationError = + mapAxiosErrorToResponseError(error); + + return ApplicationError.fromApiError(apiError, translationKey); + } + + static fromApiError( + apiError: ApiResponseError | ApiValidationError, + translationKey = "error.generic" + ): ApplicationError { + return new ApplicationError({ + statusCode: apiError.code, + message: `${apiError.title}: ${apiError.message}`, + translationKey, + }); + } } diff --git a/src/modules/data/application-error/applicationError.unit.ts b/src/modules/data/application-error/applicationError.unit.ts new file mode 100644 index 0000000..57d2de4 --- /dev/null +++ b/src/modules/data/application-error/applicationError.unit.ts @@ -0,0 +1,126 @@ +import { defaultApiError, mapAxiosErrorToResponseError } from "@/utils/api"; +import { + apiResponseErrorFactory, + applicationErrorFactory, + axiosErrorFactory, +} from "@@/tests/test-utils/factory"; +import { ApplicationError } from "./applicationError"; + +describe(ApplicationError.name, () => { + describe("fromUnknown", () => { + describe("when the error is already an application error", () => { + const setup = () => { + const error = applicationErrorFactory.build(); + + return { + error, + }; + }; + + it("should return the application error", () => { + const { error } = setup(); + + const result = ApplicationError.fromUnknown(error); + + expect(result).toEqual(error); + }); + }); + + describe("when the error is an axios error", () => { + const setup = () => { + const error = axiosErrorFactory.build(); + + return { + error, + }; + }; + + it("should return an application error from the api error", () => { + const { error } = setup(); + + const result = ApplicationError.fromUnknown(error); + + expect(result).toEqual( + ApplicationError.fromApiError(mapAxiosErrorToResponseError(error)) + ); + }); + }); + + describe("when the error is not a known type", () => { + const setup = () => { + const error = new Error("test"); + + return { + error, + }; + }; + + it("should return a generic application error from the default api error", () => { + const { error } = setup(); + + const result = ApplicationError.fromUnknown(error); + + expect(result).toEqual( + new ApplicationError({ + statusCode: defaultApiError.code, + message: `${defaultApiError.title}: ${defaultApiError.message}`, + translationKey: "error.generic", + }) + ); + }); + }); + }); + + describe("fromApiError", () => { + describe("when using the default translation key", () => { + const setup = () => { + const apiError = apiResponseErrorFactory.build(); + + return { + apiError, + }; + }; + + it("should return an application error with a generic message", () => { + const { apiError } = setup(); + + const result = ApplicationError.fromApiError(apiError); + + expect(result).toEqual( + new ApplicationError({ + statusCode: apiError.code, + message: `${apiError.title}: ${apiError.message}`, + translationKey: "error.generic", + }) + ); + }); + }); + + describe("when specifying a translation key", () => { + const setup = () => { + const apiError = apiResponseErrorFactory.build(); + + return { + apiError, + }; + }; + + it("should return an application error with the translation key", () => { + const { apiError } = setup(); + + const result = ApplicationError.fromApiError( + apiError, + "error.not.generic" + ); + + expect(result).toEqual( + new ApplicationError({ + statusCode: apiError.code, + message: `${apiError.title}: ${apiError.message}`, + translationKey: "error.not.generic", + }) + ); + }); + }); + }); +}); diff --git a/src/modules/data/auth/auth.store.ts b/src/modules/data/auth/auth.store.ts index fbb091b..2e16278 100644 --- a/src/modules/data/auth/auth.store.ts +++ b/src/modules/data/auth/auth.store.ts @@ -1,28 +1,83 @@ -import { MeApiFactory, MeApiInterface, MeResponse } from "@/serverApi/v3"; +import { + AuthenticationApiFactory, + AuthenticationApiInterface, + MeApiFactory, + MeApiInterface, + MeResponse, + MeRoleResponse, + RoleName, +} from "@/serverApi/v3"; import { $axios } from "@/utils/api"; +import { ApplicationError, HttpStatusCode } from "@data/application-error"; import { defineStore } from "pinia"; import { computed, ComputedRef, Ref, ref } from "vue"; +import { useJwtCookie } from "./jwtCookie.composable"; export const useAuthStore = defineStore("auth", () => { + const { hasJwt, setJwt, removeJwt } = useJwtCookie(); + const me: Ref = ref(null); const meApi = (): MeApiInterface => { return MeApiFactory(undefined, "v3", $axios); }; - const login = async (): Promise => { + const authenticationApi = (): AuthenticationApiInterface => { + return AuthenticationApiFactory(undefined, "v3", $axios); + }; + + const login = async (username: string, password: string): Promise => { + const { data } = await authenticationApi().loginControllerLoginLocal({ + username, + password, + }); + + setJwt(data.accessToken); + + await fetchMe(); + + if ( + !me.value?.roles.find( + (role: MeRoleResponse): boolean => role.name === RoleName.SUPERHERO + ) + ) { + await logout(); + + throw new ApplicationError({ + statusCode: HttpStatusCode.Forbidden, + message: "User is not a superhero", + translationKey: "error.login.noSuperhero", + }); + } + }; + + const logout = async (): Promise => { + try { + await $axios.delete("/v1/authentication"); + } catch (e: unknown) { + // Ignore error + } + + me.value = null; + + removeJwt(); + }; + + const fetchMe = async (): Promise => { const { data } = await meApi().meControllerMe(); me.value = data; }; const isLoggedIn: ComputedRef = computed(() => { - return me.value !== null; + return me.value !== null && hasJwt(); }); return { me, login, + logout, + fetchMe, isLoggedIn, }; }); diff --git a/src/modules/data/auth/auth.store.unit.ts b/src/modules/data/auth/auth.store.unit.ts index 089ba2e..49dc414 100644 --- a/src/modules/data/auth/auth.store.unit.ts +++ b/src/modules/data/auth/auth.store.unit.ts @@ -1,54 +1,216 @@ +import { RoleName } from "@/serverApi/v3"; +import * as serverAuthenticationApi from "@/serverApi/v3/api/authentication-api"; import * as serverMeApi from "@/serverApi/v3/api/me-api"; -import { meResponseFactory } from "@@/tests/test-utils/factory"; +import { initializeAxios } from "@/utils/api"; +import { + loginResponseFactory, + meResponseFactory, +} from "@@/tests/test-utils/factory"; import { mockApiResponse } from "@@/tests/test-utils/mockApiResponse"; import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { AxiosInstance } from "axios"; import { createPinia, setActivePinia } from "pinia"; import { useAuthStore } from "./auth.store"; +import { useJwtCookie } from "./jwtCookie.composable"; + +jest.mock("./jwtCookie.composable"); describe("AuthStore", () => { let meApi: DeepMocked; + let authenticationApi: DeepMocked; + let useJwtCookieMock: DeepMocked>; + let axiosMock: DeepMocked; beforeEach(() => { setActivePinia(createPinia()); meApi = createMock(); + authenticationApi = + createMock(); + useJwtCookieMock = createMock>(); + axiosMock = createMock(); jest.spyOn(serverMeApi, "MeApiFactory").mockReturnValue(meApi); + jest + .spyOn(serverAuthenticationApi, "AuthenticationApiFactory") + .mockReturnValue(authenticationApi); + jest.mocked(useJwtCookie).mockReturnValue(useJwtCookieMock); + initializeAxios(axiosMock); }); afterEach(() => { - jest.clearAllMocks(); + jest.resetAllMocks(); }); describe("login", () => { - describe("when logging in", () => { + describe("when the user is a superhero", () => { const setup = () => { const store = useAuthStore(); - const meResponse = meResponseFactory.build(); + const username = "username1"; + const password = "password1"; + const loginResponse = loginResponseFactory.build(); + const meResponse = meResponseFactory.build({ + roles: [{ id: "superheroRole", name: RoleName.SUPERHERO }], + }); + authenticationApi.loginControllerLoginLocal.mockResolvedValueOnce( + mockApiResponse({ data: loginResponse }) + ); meApi.meControllerMe.mockResolvedValueOnce( mockApiResponse({ data: meResponse }) ); return { store, + username, + password, meResponse, + loginResponse, }; }; + it("should set the jwt", async () => { + const { store, username, password, loginResponse } = setup(); + + await store.login(username, password); + + expect(useJwtCookieMock.setJwt).toHaveBeenCalledWith( + loginResponse.accessToken + ); + }); + it("should set me", async () => { - const { store, meResponse } = setup(); + const { store, username, password, meResponse } = setup(); - await store.login(); + await store.login(username, password); expect(store.me).toEqual(meResponse); }); }); + + describe("when the user is not a superhero", () => { + const setup = () => { + const store = useAuthStore(); + + const username = "username1"; + const password = "password1"; + const loginResponse = loginResponseFactory.build(); + const meResponse = meResponseFactory.build({ + roles: [{ id: "adminRole", name: RoleName.ADMINISTRATOR }], + }); + + authenticationApi.loginControllerLoginLocal.mockResolvedValueOnce( + mockApiResponse({ data: loginResponse }) + ); + meApi.meControllerMe.mockResolvedValueOnce( + mockApiResponse({ data: meResponse }) + ); + + let hasJwtCookie = false; + useJwtCookieMock.setJwt.mockImplementation(() => (hasJwtCookie = true)); + useJwtCookieMock.removeJwt.mockImplementation( + () => (hasJwtCookie = false) + ); + + return { + store, + username, + password, + meResponse, + loginResponse, + hasJwtCookie, + }; + }; + + it("should log the user out", async () => { + const { store, username, password, hasJwtCookie } = setup(); + + await expect(store.login(username, password)).rejects.toThrow(); + + expect(axiosMock.delete).toHaveBeenCalledWith("/v1/authentication"); + expect(store.me).toEqual(null); + expect(hasJwtCookie).toEqual(false); + }); + }); }); - describe("isLoggedIn", () => { - describe("when logged in", () => { + describe("logout", () => { + describe("when the logout api call succeeds", () => { + const setup = () => { + const store = useAuthStore(); + store.me = meResponseFactory.build(); + + return { + store, + }; + }; + + it("should log the user out", async () => { + const { store } = setup(); + + await store.logout(); + + expect(axiosMock.delete).toHaveBeenCalledWith("/v1/authentication"); + }); + + it("should reset me", async () => { + const { store } = setup(); + + await store.logout(); + + expect(store.me).toEqual(null); + }); + + it("should remove the jwt", async () => { + const { store } = setup(); + + await store.logout(); + + expect(useJwtCookieMock.removeJwt).toHaveBeenCalled(); + }); + }); + + describe("when the logout api call fails", () => { + const setup = () => { + const store = useAuthStore(); + store.me = meResponseFactory.build(); + + axiosMock.delete.mockRejectedValueOnce(new Error()); + + return { + store, + }; + }; + + it("should log the user out", async () => { + const { store } = setup(); + + await store.logout(); + + expect(axiosMock.delete).toHaveBeenCalledWith("/v1/authentication"); + }); + + it("should reset me", async () => { + const { store } = setup(); + + await store.logout(); + + expect(store.me).toEqual(null); + }); + + it("should remove the jwt", async () => { + const { store } = setup(); + + await store.logout(); + + expect(useJwtCookieMock.removeJwt).toHaveBeenCalled(); + }); + }); + }); + + describe("fetchMe", () => { + describe("when loading the users data", () => { const setup = () => { const store = useAuthStore(); @@ -58,6 +220,31 @@ describe("AuthStore", () => { mockApiResponse({ data: meResponse }) ); + return { + store, + meResponse, + }; + }; + + it("should set me", async () => { + const { store, meResponse } = setup(); + + await store.fetchMe(); + + expect(store.me).toEqual(meResponse); + }); + }); + }); + + describe("isLoggedIn", () => { + describe("when logged in", () => { + const setup = () => { + const store = useAuthStore(); + + store.me = meResponseFactory.build(); + + useJwtCookieMock.hasJwt.mockReturnValue(true); + return { store, }; @@ -66,8 +253,6 @@ describe("AuthStore", () => { it("should return true", async () => { const { store } = setup(); - await store.login(); - expect(store.isLoggedIn).toEqual(true); }); }); diff --git a/src/modules/data/auth/index.ts b/src/modules/data/auth/index.ts index 6030aba..dcfe808 100644 --- a/src/modules/data/auth/index.ts +++ b/src/modules/data/auth/index.ts @@ -1 +1,2 @@ export { useAuthStore } from "./auth.store"; +export { useJwtCookie } from "./jwtCookie.composable"; diff --git a/src/modules/data/auth/jwtCookie.composable.ts b/src/modules/data/auth/jwtCookie.composable.ts new file mode 100644 index 0000000..e69e417 --- /dev/null +++ b/src/modules/data/auth/jwtCookie.composable.ts @@ -0,0 +1,41 @@ +import axios from "axios"; +import Cookies from "universal-cookie"; + +export const useJwtCookie = () => { + const getJwt = (): string | undefined => { + const cookies = new Cookies(); + + const jwt: string | undefined = cookies.get("jwt"); + + return jwt; + }; + + const hasJwt = (): boolean => { + return !!getJwt(); + }; + + const setJwt = (jwt: string): void => { + const cookies = new Cookies(); + + cookies.set("jwt", jwt, { + expires: new Date(Date.now() + 24 * 60 * 60 * 1000), + }); + + axios.defaults.headers.common["Authorization"] = "Bearer " + jwt; + }; + + const removeJwt = (): void => { + const cookies = new Cookies(); + + cookies.remove("jwt"); + + delete axios.defaults.headers.common["Authorization"]; + }; + + return { + getJwt, + hasJwt, + setJwt, + removeJwt, + }; +}; diff --git a/src/modules/data/auth/jwtCookie.composable.unit.ts b/src/modules/data/auth/jwtCookie.composable.unit.ts new file mode 100644 index 0000000..b0903b2 --- /dev/null +++ b/src/modules/data/auth/jwtCookie.composable.unit.ts @@ -0,0 +1,121 @@ +import axios from "axios"; +import { useJwtCookie } from "./jwtCookie.composable"; + +jest.mock("axios"); + +describe("Jwt Cookie Utils", () => { + beforeEach(() => { + document.cookie = `jwt=1; expires=1 Jan 1970 00:00:00 GMT;`; + }); + + describe("getJwt", () => { + describe("when there is no jwt cookie", () => { + it("should return undefined", () => { + const result = useJwtCookie().getJwt(); + + expect(result).toEqual(undefined); + }); + }); + + describe("when there is a jwt cookie", () => { + const setup = () => { + const cookieValue = "testValue"; + + document.cookie = `jwt=${cookieValue}`; + + return { + cookieValue, + }; + }; + + it("should return the cookie value", () => { + const { cookieValue } = setup(); + + const result = useJwtCookie().getJwt(); + + expect(result).toEqual(cookieValue); + }); + }); + }); + + describe("hasJwt", () => { + describe("when there is no jwt cookie", () => { + it("should return false", () => { + const result = useJwtCookie().hasJwt(); + + expect(result).toEqual(false); + }); + }); + + describe("when there is a jwt cookie", () => { + const setup = () => { + document.cookie = "jwt=testValue"; + }; + + it("should return true", () => { + setup(); + + const result = useJwtCookie().hasJwt(); + + expect(result).toEqual(true); + }); + }); + }); + + describe("setJwt", () => { + describe("when setting a jwt cookie", () => { + const setup = () => { + const cookieValue = "testValue"; + + return { + cookieValue, + }; + }; + + it("should return set the cookie", () => { + const { cookieValue } = setup(); + + useJwtCookie().setJwt(cookieValue); + + expect(document.cookie).toEqual(`jwt=${cookieValue}`); + }); + + it("should return set the bearer token for axios", () => { + const { cookieValue } = setup(); + + useJwtCookie().setJwt(cookieValue); + + expect(axios.defaults.headers.common["Authorization"]).toEqual( + `Bearer ${cookieValue}` + ); + }); + }); + }); + + describe("removeJwt", () => { + describe("when removing the jwt", () => { + const setup = () => { + document.cookie = "jwt=testValue"; + axios.defaults.headers.common["Authorization"] = "testValue"; + }; + + it("should clear the jwt cookie", () => { + setup(); + + useJwtCookie().removeJwt(); + + expect(document.cookie).toEqual(""); + }); + + it("should clear the bearer token for axios", () => { + setup(); + + useJwtCookie().removeJwt(); + + expect(axios.defaults.headers.common["Authorization"]).toEqual( + undefined + ); + }); + }); + }); +}); diff --git a/src/modules/feature/logout/LogoutBtn.unit.ts b/src/modules/feature/logout/LogoutBtn.unit.ts new file mode 100644 index 0000000..3432151 --- /dev/null +++ b/src/modules/feature/logout/LogoutBtn.unit.ts @@ -0,0 +1,103 @@ +import { meResponseFactory } from "@@/tests/test-utils/factory"; +import { + createTestingI18n, + createTestingVuetify, +} from "@@/tests/test-utils/setup"; +import { useAuthStore } from "@data/auth"; +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { createTestingPinia } from "@pinia/testing"; +import { mount } from "@vue/test-utils"; +import { setActivePinia } from "pinia"; +import { Router, useRouter } from "vue-router"; +import LogoutBtn from "./LogoutBtn.vue"; + +jest.mock("vue-router"); + +describe("LogoutBtn", () => { + let routerMock: DeepMocked; + + const getWrapper = () => { + const wrapper = mount(LogoutBtn, { + global: { + plugins: [createTestingVuetify(), createTestingI18n()], + }, + }); + + return { + wrapper, + }; + }; + + beforeEach(() => { + setActivePinia(createTestingPinia()); + + routerMock = createMock(); + + jest.mocked(useRouter).mockReturnValue(routerMock); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("Username", () => { + const setup = () => { + const firstName = "firstName"; + const lastName = "lastName"; + const me = meResponseFactory.build({ + user: { + firstName, + lastName, + }, + }); + const authStore = useAuthStore(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + authStore.isLoggedIn = true; + authStore.me = me; + + const { wrapper } = getWrapper(); + + return { + wrapper, + authStore, + firstName, + lastName, + }; + }; + + it("should display the users name", async () => { + const { wrapper, firstName, lastName } = setup(); + + const username = wrapper.find("[data-testid=username]"); + + expect(username.text()).toEqual(`${firstName} ${lastName}`); + }); + }); + + describe("when clicking the logout button", () => { + const setup = () => { + const authStore = useAuthStore(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + authStore.isLoggedIn = true; + + const { wrapper } = getWrapper(); + + return { + wrapper, + authStore, + }; + }; + + it("should log the user out and redirect to login", async () => { + const { wrapper, authStore } = setup(); + + const logoutBtn = wrapper.find("[data-testid=logout-btn]"); + await logoutBtn.trigger("click"); + + expect(authStore.logout).toHaveBeenCalled(); + expect(routerMock.push).toHaveBeenCalledWith("/login"); + }); + }); +}); diff --git a/src/modules/feature/logout/LogoutBtn.vue b/src/modules/feature/logout/LogoutBtn.vue new file mode 100644 index 0000000..4306203 --- /dev/null +++ b/src/modules/feature/logout/LogoutBtn.vue @@ -0,0 +1,24 @@ + + + diff --git a/src/modules/feature/logout/index.ts b/src/modules/feature/logout/index.ts new file mode 100644 index 0000000..e3f284f --- /dev/null +++ b/src/modules/feature/logout/index.ts @@ -0,0 +1,3 @@ +import LogoutBtn from "./LogoutBtn.vue"; + +export { LogoutBtn }; diff --git a/src/modules/page/AboutView.unit.ts b/src/modules/page/AboutView.unit.ts deleted file mode 100644 index 4b458b3..0000000 --- a/src/modules/page/AboutView.unit.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { createTestingVuetify } from "@@/tests/test-utils/setup"; -import { mount } from "@vue/test-utils"; -import { VBtn } from "vuetify/lib/components/index.mjs"; -import AboutView from "./AboutView.vue"; - -describe("AboutView", () => { - const getWrapper = () => { - const wrapper = mount(AboutView, { - global: { plugins: [createTestingVuetify()] }, - }); - - return { - wrapper, - }; - }; - - it("should increase the counter button on click", async () => { - const { wrapper } = getWrapper(); - - const counter = wrapper.getComponent(VBtn); - await counter.trigger("click"); - - expect(counter.text()).toEqual("1"); - }); -}); diff --git a/src/modules/page/AboutView.vue b/src/modules/page/AboutView.vue deleted file mode 100644 index c45afce..0000000 --- a/src/modules/page/AboutView.vue +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/src/modules/page/HomeView.vue b/src/modules/page/HomeView.vue index 5e481cf..8e8e9c6 100644 --- a/src/modules/page/HomeView.vue +++ b/src/modules/page/HomeView.vue @@ -1,7 +1,5 @@ diff --git a/src/modules/page/Login.page.unit.ts b/src/modules/page/Login.page.unit.ts new file mode 100644 index 0000000..d224314 --- /dev/null +++ b/src/modules/page/Login.page.unit.ts @@ -0,0 +1,182 @@ +import { postLoginRoute } from "@/router/postLoginRoute"; +import { + createTestingI18n, + createTestingVuetify, +} from "@@/tests/test-utils/setup"; +import { useAuthStore } from "@data/auth"; +import { createMock, DeepMocked } from "@golevelup/ts-jest"; +import { createTestingPinia } from "@pinia/testing"; +import { mount } from "@vue/test-utils"; +import { setActivePinia } from "pinia"; +import { Router, useRouter } from "vue-router"; +import LoginPage from "./Login.page.vue"; + +jest.mock("vue-router"); + +describe("LoginPage", () => { + let routerMock: DeepMocked; + + const getWrapper = () => { + const wrapper = mount(LoginPage, { + global: { + plugins: [createTestingVuetify(), createTestingI18n()], + }, + }); + + return { + wrapper, + }; + }; + + beforeEach(() => { + setActivePinia(createTestingPinia()); + + routerMock = createMock(); + + jest.mocked(useRouter).mockReturnValue(routerMock); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("Login", () => { + describe("when logging in as a superhero", () => { + const setup = () => { + const username = "username@test.com"; + const password = "password1"; + const authStore = useAuthStore(); + + const { wrapper } = getWrapper(); + + return { + wrapper, + authStore, + username, + password, + }; + }; + + it("should login the user and redirect to the home page", async () => { + const { wrapper, username, password, authStore } = setup(); + + const usernameInput = wrapper.find( + "[data-testid=login-input-username] input" + ); + const passwordInput = wrapper.find( + "[data-testid=login-input-password] input" + ); + const loginBtn = wrapper.find("[data-testid=login-btn]"); + + await usernameInput.setValue(username); + await passwordInput.setValue(password); + await loginBtn.trigger("click"); + + expect(authStore.login).toHaveBeenCalledWith(username, password); + expect(routerMock.push).toHaveBeenCalledWith(postLoginRoute); + }); + }); + + describe("when a redirect query is defined", () => { + const setup = () => { + const username = "username@test.com"; + const password = "password1"; + const redirect = "/test"; + const authStore = useAuthStore(); + + routerMock.currentRoute.value.query.redirect = redirect; + + const { wrapper } = getWrapper(); + + return { + wrapper, + authStore, + username, + password, + redirect, + }; + }; + + it("should redirect to the specified page", async () => { + const { wrapper, username, password, redirect } = setup(); + + const usernameInput = wrapper.find( + "[data-testid=login-input-username] input" + ); + const passwordInput = wrapper.find( + "[data-testid=login-input-password] input" + ); + const loginBtn = wrapper.find("[data-testid=login-btn]"); + + await usernameInput.setValue(username); + await passwordInput.setValue(password); + await loginBtn.trigger("click"); + + expect(routerMock.push).toHaveBeenCalledWith(redirect); + }); + }); + + describe("when the login fails", () => { + const setup = () => { + const username = "username@test.com"; + const password = "password1"; + const authStore = useAuthStore(); + + authStore.login = jest.fn().mockRejectedValueOnce(new Error()); + + const { wrapper } = getWrapper(); + + return { + wrapper, + authStore, + username, + password, + }; + }; + + it("should display an error", async () => { + const { wrapper, username, password } = setup(); + + const usernameInput = wrapper.find( + "[data-testid=login-input-username] input" + ); + const passwordInput = wrapper.find( + "[data-testid=login-input-password] input" + ); + const loginBtn = wrapper.find("[data-testid=login-btn]"); + + await usernameInput.setValue(username); + await passwordInput.setValue(password); + await loginBtn.trigger("click"); + + const errorAlert = wrapper.find("[data-testid=login-error]"); + + expect(errorAlert.isVisible()).toEqual(true); + expect(errorAlert.text()).toBeTruthy(); + expect(routerMock.push).not.toHaveBeenCalled(); + }); + }); + + describe("when no username or password is defined", () => { + const setup = () => { + const authStore = useAuthStore(); + + const { wrapper } = getWrapper(); + + return { + wrapper, + authStore, + }; + }; + + it("should do nothing", async () => { + const { wrapper, authStore } = setup(); + + const loginBtn = wrapper.find("[data-testid=login-btn]"); + await loginBtn.trigger("click"); + + expect(authStore.login).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/modules/page/Login.page.vue b/src/modules/page/Login.page.vue new file mode 100644 index 0000000..530982f --- /dev/null +++ b/src/modules/page/Login.page.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/src/plugins/applicationErrorHandler.ts b/src/plugins/applicationErrorHandler.ts index 52c1def..a9cdc80 100644 --- a/src/plugins/applicationErrorHandler.ts +++ b/src/plugins/applicationErrorHandler.ts @@ -1,5 +1,6 @@ import { ApplicationError, + HttpStatusCode, useApplicationErrorStore, } from "@data/application-error"; @@ -19,7 +20,7 @@ export const handleApplicationError = (err: unknown) => { } else { applicationErrorStore.setError( new ApplicationError({ - statusCode: 500, + statusCode: HttpStatusCode.InternalServerError, translationKey: "error.generic", }) ); diff --git a/src/router/guards/is-authenticated.guard.ts b/src/router/guards/is-authenticated.guard.ts index fe8eadc..d958647 100644 --- a/src/router/guards/is-authenticated.guard.ts +++ b/src/router/guards/is-authenticated.guard.ts @@ -1,23 +1,37 @@ import { useAuthStore } from "@data/auth"; -import { NavigationGuardNext, RouteLocationNormalized } from "vue-router"; -import { getLoginUrlWithRedirect } from "./login-redirect-url"; +import { + NavigationGuard, + NavigationGuardReturn, + RouteLocationNormalized, +} from "vue-router"; +import { postLoginRoute } from "../postLoginRoute"; -export const isAuthenticatedGuard = ( - to: RouteLocationNormalized, - from: RouteLocationNormalized, - next: NavigationGuardNext -) => { +export const isAuthenticatedGuard: NavigationGuard = ( + to: RouteLocationNormalized +): NavigationGuardReturn => { const userIsLoggedIn = useAuthStore().isLoggedIn; - if (userIsLoggedIn || isRoutePublic(to)) { - next(); + if (userIsLoggedIn && to.path.startsWith("/login")) { + window.location.assign(postLoginRoute); + } else if (userIsLoggedIn || isRoutePublic(to)) { + return true; } else { - const loginUrl = getLoginUrlWithRedirect(to.fullPath); - window.location.assign(loginUrl); + const loginUrl = new URL("/login", window.location.origin); + loginUrl.searchParams.set("redirect", to.fullPath); + + const relativePath = toPathString(loginUrl); + + window.location.assign(relativePath); } + + return false; +}; + +export const toPathString = (url: URL): string => { + return url.pathname + url.search + url.hash; }; -const isRoutePublic = (route: RouteLocationNormalized) => { +const isRoutePublic = (route: RouteLocationNormalized): boolean => { if (typeof route.meta?.isPublic === "boolean") { return route.meta.isPublic; } else { diff --git a/src/router/guards/is-authenticated.unit.ts b/src/router/guards/is-authenticated.unit.ts index d500338..5a3a936 100644 --- a/src/router/guards/is-authenticated.unit.ts +++ b/src/router/guards/is-authenticated.unit.ts @@ -1,17 +1,26 @@ -import { meResponseFactory } from "@@/tests/test-utils/factory"; +import { routeLocationNormalizedFactory } from "@@/tests/test-utils/factory"; import { useAuthStore } from "@data/auth"; import { createTestingPinia } from "@pinia/testing"; import { setActivePinia } from "pinia"; import { RouteLocationNormalized } from "vue-router"; +import { postLoginRoute } from "../postLoginRoute"; import { isAuthenticatedGuard } from "./is-authenticated.guard"; -jest.mock("./login-redirect-url", () => ({ - getLoginUrlWithRedirect: () => "login-url", -})); - describe("Authentication Guard", () => { + const from: RouteLocationNormalized = {} as RouteLocationNormalized; + const next = jest.fn(); + beforeEach(() => { setActivePinia(createTestingPinia()); + + const assign = jest.fn(); + Object.defineProperty(window, "location", { + configurable: true, + value: { + assign, + origin: "https://test.com", + }, + }); }); afterEach(() => { @@ -21,41 +30,55 @@ describe("Authentication Guard", () => { describe("isAuthenticatedGuard", () => { describe("when authenticated", () => { const setup = () => { - const to: RouteLocationNormalized = { - fullPath: "/test", - } as RouteLocationNormalized; - const from: RouteLocationNormalized = {} as RouteLocationNormalized; - const next = jest.fn(); + const to = routeLocationNormalizedFactory("/test"); + const authStore = useAuthStore(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + authStore.isLoggedIn = true; + + return { + to, + }; + }; + + it("should pass", async () => { + const { to } = setup(); - authStore.me = meResponseFactory.build(); + const result = await isAuthenticatedGuard(to, from, next); + + expect(result).toEqual(true); + }); + }); + + describe("when accessing the login page while authenticated", () => { + const setup = () => { + const to = routeLocationNormalizedFactory("/login"); + + const authStore = useAuthStore(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + authStore.isLoggedIn = true; return { to, - from, - next, }; }; - it("should pass", () => { - const { to, from, next } = setup(); + it("should redirect to the main page", async () => { + const { to } = setup(); - isAuthenticatedGuard(to, from, next); + await isAuthenticatedGuard(to, from, next); - expect(next).toHaveBeenCalled(); + expect(window.location.assign).toHaveBeenCalledWith(postLoginRoute); }); }); - describe("when the url is public", () => { + describe("when not authenticated and the url is public", () => { const setup = () => { - const to: RouteLocationNormalized = { - fullPath: "/test", - meta: { - isPublic: true, - }, - } as unknown as RouteLocationNormalized; - const from: RouteLocationNormalized = {} as RouteLocationNormalized; - const next = jest.fn(); + const to = routeLocationNormalizedFactory("/test", { + meta: { isPublic: true }, + }); const authStore = useAuthStore(); authStore.me = null; @@ -67,54 +90,37 @@ describe("Authentication Guard", () => { }; }; - it("should pass", () => { - const { to, from, next } = setup(); + it("should pass", async () => { + const { to } = setup(); - isAuthenticatedGuard(to, from, next); + const result = await isAuthenticatedGuard(to, from, next); - expect(next).toHaveBeenCalled(); + expect(result).toEqual(true); }); }); describe("when not authenticated and the url is not public", () => { const setup = () => { - const to: RouteLocationNormalized = { - fullPath: "/test", - } as RouteLocationNormalized; - const from: RouteLocationNormalized = {} as RouteLocationNormalized; - const next = jest.fn(); + const to = routeLocationNormalizedFactory("/test?param1=value1#hash1"); const authStore = useAuthStore(); authStore.me = null; - const assign = jest.fn(); - Object.defineProperty(window, "location", { - configurable: true, - value: { assign }, - }); - return { to, from, next, - assign, }; }; - it("should redirect to login", () => { - const { to, from, next, assign } = setup(); - - isAuthenticatedGuard(to, from, next); - - expect(assign).toHaveBeenCalledWith("login-url"); - }); - - it("should not pass", () => { - const { to, from, next } = setup(); + it("should redirect to login with post login redirect query", async () => { + const { to } = setup(); - isAuthenticatedGuard(to, from, next); + await isAuthenticatedGuard(to, from, next); - expect(next).not.toHaveBeenCalled(); + expect(window.location.assign).toHaveBeenCalledWith( + "/login?redirect=%2Ftest%3Fparam1%3Dvalue1%23hash1" + ); }); }); }); diff --git a/src/router/guards/login-redirect-url.ts b/src/router/guards/login-redirect-url.ts deleted file mode 100644 index 32d9aee..0000000 --- a/src/router/guards/login-redirect-url.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { useEnvConfigStore } from "@data/env-config"; - -export const getLoginUrlWithRedirect = (targetPath: string): string => { - const currentOrigin = window.location.origin; - - const currentUrl = new URL(targetPath, currentOrigin); - const loginUrl = new URL( - useEnvConfigStore().getEnvs.NOT_AUTHENTICATED_REDIRECT_URL, - currentOrigin // fallback to current origin, if a relative url is configured - ); - - const isInternalUrl = currentUrl.origin === loginUrl.origin; - - if (isInternalUrl) { - return addRedirectAsParam(loginUrl, currentUrl); - } - - return addRedirectAsParamToUrlParams(loginUrl, currentUrl); -}; - -const addRedirectAsParam = (loginUrl: URL, currentUrl: URL) => { - loginUrl.searchParams.set("redirect", currentUrl.toString()); - return loginUrl.toString(); -}; - -const addRedirectAsParamToUrlParams = (loginUrl: URL, currentUrl: URL) => { - for (const [name, value] of loginUrl.searchParams.entries()) { - const isSchulcloudUrl = value.indexOf(currentUrl.origin) === 0; - if (isSchulcloudUrl) { - const urlInParameters = new URL(value); - urlInParameters.searchParams.set("redirect", currentUrl.toString()); - loginUrl.searchParams.set(name, urlInParameters.toString()); - } - } - - return loginUrl.toString(); -}; diff --git a/src/router/guards/login-redirect-url.unit.ts b/src/router/guards/login-redirect-url.unit.ts deleted file mode 100644 index dd1d479..0000000 --- a/src/router/guards/login-redirect-url.unit.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { envsFactory } from "@@/tests/test-utils/factory"; -import { useEnvConfigStore } from "@data/env-config"; -import { createPinia, setActivePinia } from "pinia"; -import { getLoginUrlWithRedirect } from "./login-redirect-url"; - -describe("Login Redirect Util", () => { - beforeEach(() => { - setActivePinia(createPinia()); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe("getLoginUrlWithRedirect", () => { - describe("when redirecting to internal login before redirecting to the target url", () => { - const setup = () => { - const loginUrl = "https://test.com/login"; - const targetPath = "/dashboard"; - const origin = "https://test.com"; - const envConfigStore = useEnvConfigStore(); - - envConfigStore.setEnvs( - envsFactory.build({ - NOT_AUTHENTICATED_REDIRECT_URL: loginUrl, - }) - ); - - jest.spyOn(window, "location", "get").mockReturnValue({ - origin: origin, - } as Location); - - return { - loginUrl, - targetPath, - origin, - }; - }; - - it("should redirect to internal login with post-login-redirect to internal target url", () => { - const { loginUrl, targetPath, origin } = setup(); - - const result = getLoginUrlWithRedirect(targetPath); - - expect(result).toEqual( - `${loginUrl}?redirect=${encodeURIComponent(`${origin}${targetPath}`)}` - ); - }); - }); - - describe("when redirecting to an external login before redirecting to the target url", () => { - const setup = () => { - const origin = "https://test.com"; - const loginUrl = `https://external-login.thr/login?service=${encodeURIComponent(origin)}`; - const targetPath = "/dashboard"; - const envConfigStore = useEnvConfigStore(); - - envConfigStore.setEnvs( - envsFactory.build({ - NOT_AUTHENTICATED_REDIRECT_URL: loginUrl, - }) - ); - - jest.spyOn(window, "location", "get").mockReturnValue({ - origin: origin, - } as Location); - - return { - loginUrl, - targetPath, - origin, - }; - }; - - it("should redirect to external login with post-login-redirect to internal target url", () => { - const { loginUrl, targetPath, origin } = setup(); - - const result = getLoginUrlWithRedirect(targetPath); - - const redirectUri = encodeURIComponent(`${origin}${targetPath}`); - expect(result).toEqual( - `${loginUrl}${encodeURIComponent(`/?redirect=${redirectUri}`)}` - ); - }); - }); - }); -}); diff --git a/src/router/index.ts b/src/router/index.ts index 7ddb1a6..b4fc67b 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,4 +1,5 @@ import { createRouter, createWebHistory } from "vue-router"; +import { isAuthenticatedGuard } from "./guards"; import { routes } from "./routes"; const router = createRouter({ @@ -6,7 +7,6 @@ const router = createRouter({ routes, }); -// TODO N21-2133: Add authentication -// router.beforeEach(isAuthenticatedGuard); +router.beforeResolve(isAuthenticatedGuard); export default router; diff --git a/src/router/postLoginRoute.ts b/src/router/postLoginRoute.ts new file mode 100644 index 0000000..b6494f8 --- /dev/null +++ b/src/router/postLoginRoute.ts @@ -0,0 +1 @@ +export const postLoginRoute = "/"; diff --git a/src/router/routes.ts b/src/router/routes.ts index 3de40d2..c195ded 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -9,8 +9,11 @@ export const routes: Readonly = [ component: HomeView, }, { - path: "/about", - name: "about", - component: () => import("@page/AboutView.vue"), + path: "/login", + name: "login", + component: () => import("@page/Login.page.vue"), + meta: { + isPublic: true, + }, }, ]; diff --git a/src/serverApi/v3/.openapi-generator/FILES b/src/serverApi/v3/.openapi-generator/FILES index 2d22a2f..25d421d 100644 --- a/src/serverApi/v3/.openapi-generator/FILES +++ b/src/serverApi/v3/.openapi-generator/FILES @@ -1,6 +1,5 @@ .gitignore .npmignore -.openapi-generator-ignore api.ts api/account-api.ts api/admin-students-api.ts @@ -93,6 +92,7 @@ models/context-external-tool-response.ts models/context-external-tool-search-list-response.ts models/copy-api-response.ts models/county-response.ts +models/course-common-cartridge-metadata-response.ts models/course-export-body-params.ts models/course-info-response.ts models/course-metadata-list-response.ts @@ -195,6 +195,7 @@ models/oauth-client-create-body.ts models/oauth-client-response.ts models/oauth-client-update-body.ts models/oauth-config-response.ts +models/oauth-provider-login-response.ts models/oauth-token-dto.ts models/oauth2-authorization-body-params.ts models/oauth2-migration-params.ts diff --git a/src/serverApi/v3/api/courses-api.ts b/src/serverApi/v3/api/courses-api.ts index f0c56a9..7c5f3bb 100644 --- a/src/serverApi/v3/api/courses-api.ts +++ b/src/serverApi/v3/api/courses-api.ts @@ -21,6 +21,8 @@ import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObj // @ts-ignore import { BASE_PATH, COLLECTION_FORMATS, RequestArgs, BaseAPI, RequiredError } from '../base'; // @ts-ignore +import { CourseCommonCartridgeMetadataResponse } from '../models'; +// @ts-ignore import { CourseExportBodyParams } from '../models'; // @ts-ignore import { CourseMetadataListResponse } from '../models'; @@ -114,6 +116,44 @@ export const CoursesApiAxiosParamCreator = function (configuration?: Configurati + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Get common cartridge metadata of a course by Id. + * @param {string} courseId The id of the course + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + courseControllerGetCourseCcMetadataById: async (courseId: string, options: any = {}): Promise => { + // verify required parameter 'courseId' is not null or undefined + assertParamExists('courseControllerGetCourseCcMetadataById', 'courseId', courseId) + const localVarPath = `/courses/{courseId}/cc-metadata` + .replace(`{${"courseId"}}`, encodeURIComponent(String(courseId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -277,6 +317,17 @@ export const CoursesApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.courseControllerFindForUser(skip, limit, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @summary Get common cartridge metadata of a course by Id. + * @param {string} courseId The id of the course + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async courseControllerGetCourseCcMetadataById(courseId: string, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.courseControllerGetCourseCcMetadataById(courseId, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @summary Get permissions for a user in a course. @@ -341,6 +392,16 @@ export const CoursesApiFactory = function (configuration?: Configuration, basePa courseControllerFindForUser(skip?: number, limit?: number, options?: any): AxiosPromise { return localVarFp.courseControllerFindForUser(skip, limit, options).then((request) => request(axios, basePath)); }, + /** + * + * @summary Get common cartridge metadata of a course by Id. + * @param {string} courseId The id of the course + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + courseControllerGetCourseCcMetadataById(courseId: string, options?: any): AxiosPromise { + return localVarFp.courseControllerGetCourseCcMetadataById(courseId, options).then((request) => request(axios, basePath)); + }, /** * * @summary Get permissions for a user in a course. @@ -401,6 +462,16 @@ export interface CoursesApiInterface { */ courseControllerFindForUser(skip?: number, limit?: number, options?: any): AxiosPromise; + /** + * + * @summary Get common cartridge metadata of a course by Id. + * @param {string} courseId The id of the course + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof CoursesApiInterface + */ + courseControllerGetCourseCcMetadataById(courseId: string, options?: any): AxiosPromise; + /** * * @summary Get permissions for a user in a course. @@ -465,6 +536,18 @@ export class CoursesApi extends BaseAPI implements CoursesApiInterface { return CoursesApiFp(this.configuration).courseControllerFindForUser(skip, limit, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @summary Get common cartridge metadata of a course by Id. + * @param {string} courseId The id of the course + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof CoursesApi + */ + public courseControllerGetCourseCcMetadataById(courseId: string, options?: any) { + return CoursesApiFp(this.configuration).courseControllerGetCourseCcMetadataById(courseId, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @summary Get permissions for a user in a course. diff --git a/src/serverApi/v3/api/oauth2-api.ts b/src/serverApi/v3/api/oauth2-api.ts index 8b3e0e5..22eabe3 100644 --- a/src/serverApi/v3/api/oauth2-api.ts +++ b/src/serverApi/v3/api/oauth2-api.ts @@ -29,14 +29,14 @@ import { ConsentSessionResponse } from '../models'; // @ts-ignore import { LoginRequestBody } from '../models'; // @ts-ignore -import { LoginResponse } from '../models'; -// @ts-ignore import { OauthClientCreateBody } from '../models'; // @ts-ignore import { OauthClientResponse } from '../models'; // @ts-ignore import { OauthClientUpdateBody } from '../models'; // @ts-ignore +import { OauthProviderLoginResponse } from '../models'; +// @ts-ignore import { RedirectResponse } from '../models'; /** * Oauth2Api - axios parameter creator @@ -581,7 +581,7 @@ export const Oauth2ApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async oauthProviderControllerGetLoginRequest(challenge: string, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async oauthProviderControllerGetLoginRequest(challenge: string, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.oauthProviderControllerGetLoginRequest(challenge, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, @@ -714,7 +714,7 @@ export const Oauth2ApiFactory = function (configuration?: Configuration, basePat * @param {*} [options] Override http request option. * @throws {RequiredError} */ - oauthProviderControllerGetLoginRequest(challenge: string, options?: any): AxiosPromise { + oauthProviderControllerGetLoginRequest(challenge: string, options?: any): AxiosPromise { return localVarFp.oauthProviderControllerGetLoginRequest(challenge, options).then((request) => request(axios, basePath)); }, /** @@ -839,7 +839,7 @@ export interface Oauth2ApiInterface { * @throws {RequiredError} * @memberof Oauth2ApiInterface */ - oauthProviderControllerGetLoginRequest(challenge: string, options?: any): AxiosPromise; + oauthProviderControllerGetLoginRequest(challenge: string, options?: any): AxiosPromise; /** * diff --git a/src/serverApi/v3/models/course-common-cartridge-metadata-response.ts b/src/serverApi/v3/models/course-common-cartridge-metadata-response.ts new file mode 100644 index 0000000..4d6c1e7 --- /dev/null +++ b/src/serverApi/v3/models/course-common-cartridge-metadata-response.ts @@ -0,0 +1,49 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * This is v3 of Schulcloud-Verbund-Software Server. Checkout /docs for v1. + * + * The version of the OpenAPI document: 3.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * + * @export + * @interface CourseCommonCartridgeMetadataResponse + */ +export interface CourseCommonCartridgeMetadataResponse { + /** + * The id of the course + * @type {string} + * @memberof CourseCommonCartridgeMetadataResponse + */ + id: string; + /** + * Title of the course + * @type {string} + * @memberof CourseCommonCartridgeMetadataResponse + */ + title: string; + /** + * Creation date of the course + * @type {string} + * @memberof CourseCommonCartridgeMetadataResponse + */ + creationDate: string; + /** + * Copy right owners of the course + * @type {Array} + * @memberof CourseCommonCartridgeMetadataResponse + */ + copyRightOwners: Array; +} + + diff --git a/src/serverApi/v3/models/import-user-response.ts b/src/serverApi/v3/models/import-user-response.ts index ee0e1a4..1976a0b 100644 --- a/src/serverApi/v3/models/import-user-response.ts +++ b/src/serverApi/v3/models/import-user-response.ts @@ -69,6 +69,12 @@ export interface ImportUserResponse { * @memberof ImportUserResponse */ flagged: boolean; + /** + * exact user roles from the external system + * @type {Array} + * @memberof ImportUserResponse + */ + externalRoleNames?: Array; } /** diff --git a/src/serverApi/v3/models/index.ts b/src/serverApi/v3/models/index.ts index 86cca12..83ea2c8 100644 --- a/src/serverApi/v3/models/index.ts +++ b/src/serverApi/v3/models/index.ts @@ -45,6 +45,7 @@ export * from './context-external-tool-response'; export * from './context-external-tool-search-list-response'; export * from './copy-api-response'; export * from './county-response'; +export * from './course-common-cartridge-metadata-response'; export * from './course-export-body-params'; export * from './course-info-response'; export * from './course-metadata-list-response'; @@ -151,6 +152,7 @@ export * from './oauth-client-create-body'; export * from './oauth-client-response'; export * from './oauth-client-update-body'; export * from './oauth-config-response'; +export * from './oauth-provider-login-response'; export * from './oidc-context-response'; export * from './parent-consent-response'; export * from './patch-group-params'; diff --git a/src/serverApi/v3/models/login-response.ts b/src/serverApi/v3/models/login-response.ts index ace81b4..d434821 100644 --- a/src/serverApi/v3/models/login-response.ts +++ b/src/serverApi/v3/models/login-response.ts @@ -13,8 +13,6 @@ */ -import { OauthClientResponse } from './oauth-client-response'; -import { OidcContextResponse } from './oidc-context-response'; /** * @@ -22,66 +20,12 @@ import { OidcContextResponse } from './oidc-context-response'; * @interface LoginResponse */ export interface LoginResponse { - /** - * Id of the corresponding client. - * @type {string} - * @memberof LoginResponse - */ - client_id: string; - /** - * The id/challenge of the consent login request. - * @type {string} - * @memberof LoginResponse - */ - challenge: string; /** * - * @type {OauthClientResponse} - * @memberof LoginResponse - */ - client: OauthClientResponse; - /** - * - * @type {OidcContextResponse} - * @memberof LoginResponse - */ - oidc_context: OidcContextResponse; - /** - * The original oauth2.0 authorization url request by the client. - * @type {string} - * @memberof LoginResponse - */ - request_url: string; - /** - * - * @type {Array} - * @memberof LoginResponse - */ - requested_access_token_audience: Array; - /** - * The request scopes of the login request. - * @type {Array} - * @memberof LoginResponse - */ - requested_scope?: Array; - /** - * The login session id. This parameter is used as sid for the oidc front-/backchannel logout. - * @type {string} - * @memberof LoginResponse - */ - session_id: string; - /** - * Skip, if true, implies that the client has requested the same scopes from the same user previously. - * @type {boolean} - * @memberof LoginResponse - */ - skip: boolean; - /** - * User id of the end-user that is authenticated. * @type {string} * @memberof LoginResponse */ - subject: string; + accessToken: string; } diff --git a/src/serverApi/v3/models/oauth-provider-login-response.ts b/src/serverApi/v3/models/oauth-provider-login-response.ts new file mode 100644 index 0000000..ce58925 --- /dev/null +++ b/src/serverApi/v3/models/oauth-provider-login-response.ts @@ -0,0 +1,87 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schulcloud-Verbund-Software Server API + * This is v3 of Schulcloud-Verbund-Software Server. Checkout /docs for v1. + * + * The version of the OpenAPI document: 3.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import { OauthClientResponse } from './oauth-client-response'; +import { OidcContextResponse } from './oidc-context-response'; + +/** + * + * @export + * @interface OauthProviderLoginResponse + */ +export interface OauthProviderLoginResponse { + /** + * Id of the corresponding client. + * @type {string} + * @memberof OauthProviderLoginResponse + */ + client_id: string; + /** + * The id/challenge of the consent login request. + * @type {string} + * @memberof OauthProviderLoginResponse + */ + challenge: string; + /** + * + * @type {OauthClientResponse} + * @memberof OauthProviderLoginResponse + */ + client: OauthClientResponse; + /** + * + * @type {OidcContextResponse} + * @memberof OauthProviderLoginResponse + */ + oidc_context: OidcContextResponse; + /** + * The original oauth2.0 authorization url request by the client. + * @type {string} + * @memberof OauthProviderLoginResponse + */ + request_url: string; + /** + * + * @type {Array} + * @memberof OauthProviderLoginResponse + */ + requested_access_token_audience: Array; + /** + * The request scopes of the login request. + * @type {Array} + * @memberof OauthProviderLoginResponse + */ + requested_scope?: Array; + /** + * The login session id. This parameter is used as sid for the oidc front-/backchannel logout. + * @type {string} + * @memberof OauthProviderLoginResponse + */ + session_id: string; + /** + * Skip, if true, implies that the client has requested the same scopes from the same user previously. + * @type {boolean} + * @memberof OauthProviderLoginResponse + */ + skip: boolean; + /** + * User id of the end-user that is authenticated. + * @type {string} + * @memberof OauthProviderLoginResponse + */ + subject: string; +} + + diff --git a/src/styles/main.scss b/src/styles/main.scss index 3250557..a9103ff 100644 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -1,4 +1,4 @@ @use './fonts'; @use './typography'; @use './settings'; -@use 'vuetify'; \ No newline at end of file +@use 'vuetify'; diff --git a/src/styles/typography.scss b/src/styles/typography.scss index 6e99847..09042c7 100644 --- a/src/styles/typography.scss +++ b/src/styles/typography.scss @@ -4,4 +4,36 @@ $system-fonts: -apple-system, blinkmacsystemfont, "Segoe UI", roboto, ubuntu, :root { --font-primary: "PT Sans", #{$system-fonts}; --font-accent: "PT Sans Narrow", #{$system-fonts}; + + /* set base values */ + --text-base-size: 1rem; + --text-scale-ratio: 1.2; + --text-device-scale-ratio: 1.125; + + /* heading sizes */ + --heading-1: calc(var(--heading-2) * var(--text-scale-ratio)); + --heading-2: calc(var(--heading-3) * var(--text-scale-ratio)); + --heading-3: calc(var(--heading-4) * var(--text-scale-ratio)); + --heading-4: calc(var(--heading-5) * var(--text-scale-ratio)); + --heading-5: calc(var(--heading-6) * var(--text-scale-ratio)); + --heading-6: calc(var(--text-base-size) * var(--text-scale-ratio)); + + /* text sizes */ + --text-xs: calc( + var(--text-base-size) / var(--text-scale-ratio) / var(--text-scale-ratio) + ); + --text-sm: calc(var(--text-base-size) / var(--text-scale-ratio)); + --text-md: var(--text-base-size); + --text-lg: calc(var(--text-base-size) * var(--text-scale-ratio)); + + /* line-height */ + --line-height-sm: 1.05; + --line-height-md: 1.2; + --line-height-lg: 1.4; + --line-height-xl: 2; + + /* font-weight */ + --font-weight-light: 200; + --font-weight-normal: 400; + --font-weight-bold: 700; } diff --git a/src/utils/api/api.ts b/src/utils/api/api.ts index 3c0abd4..7405100 100644 --- a/src/utils/api/api.ts +++ b/src/utils/api/api.ts @@ -14,10 +14,10 @@ export const initializeAxios = (axios: AxiosInstance) => { }; export const defaultApiError: ApiResponseError | ApiValidationError = { - message: "UNKNOWN_ERROR", - code: 1, - title: "", - type: "Unknown error", + message: "Unknown error", + code: 500, + title: "Unknown Error", + type: "UNKNOWN_ERROR", }; export const mapAxiosErrorToResponseError = ( diff --git a/tests/test-utils/factory/index.ts b/tests/test-utils/factory/index.ts index 95fe836..ce38d75 100644 --- a/tests/test-utils/factory/index.ts +++ b/tests/test-utils/factory/index.ts @@ -1,4 +1,6 @@ export * from "./api"; +export * from "./router"; export * from "./envsFactory"; export * from "./meResponseFactory"; export * from "./applicationErrorFactory"; +export * from "./loginResponseFactory"; diff --git a/tests/test-utils/factory/loginResponseFactory.ts b/tests/test-utils/factory/loginResponseFactory.ts new file mode 100644 index 0000000..7542ea6 --- /dev/null +++ b/tests/test-utils/factory/loginResponseFactory.ts @@ -0,0 +1,8 @@ +import { LoginResponse } from "@/serverApi/v3"; +import { Factory } from "fishery"; + +export const loginResponseFactory = Factory.define( + ({ sequence }) => ({ + accessToken: `accessToken-${sequence}`, + }) +); diff --git a/tests/test-utils/factory/router/index.ts b/tests/test-utils/factory/router/index.ts new file mode 100644 index 0000000..df387b8 --- /dev/null +++ b/tests/test-utils/factory/router/index.ts @@ -0,0 +1 @@ +export * from "./routeLocationNormalizedFactory"; diff --git a/tests/test-utils/factory/router/routeLocationNormalizedFactory.ts b/tests/test-utils/factory/router/routeLocationNormalizedFactory.ts new file mode 100644 index 0000000..ba87d94 --- /dev/null +++ b/tests/test-utils/factory/router/routeLocationNormalizedFactory.ts @@ -0,0 +1,20 @@ +import { RouteLocationNormalized } from "vue-router"; + +export const routeLocationNormalizedFactory = ( + path: string, + overwrite: Partial = {} +): RouteLocationNormalized => { + const url = new URL(path, "https://dummy.com"); + return { + fullPath: url.pathname + url.search + url.hash, + path: url.pathname, + meta: {}, + params: {}, + query: Object.fromEntries(url.searchParams.entries()), + name: undefined, + hash: url.hash, + matched: [], + redirectedFrom: undefined, + ...overwrite, + }; +};