diff --git a/package-lock.json b/package-lock.json index baf6c8e8..9ef5a1e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,13 +8,15 @@ "name": "dapla-ctrl", "version": "0.0.0", "dependencies": { + "@esbuild-plugins/node-globals-polyfill": "^0.2.3", "@statisticsnorway/ssb-component-library": "^2.0.96", "dotenv": "^16.3.1", "express": "4.18.2", "jsonwebtoken": "^9.0.2", "jwks-rsa": "^3.1.0", "lightship": "^9.0.3", - "nodemon": "^3.0.2", + "memory-cache": "^0.2.0", + "nodemon": "^3.0.3", "react": "^18.2.0", "react-dom": "^18.2.0", "react-responsive": "^9.0.2", @@ -43,6 +45,14 @@ "node": ">=0.10.0" } }, + "node_modules/@esbuild-plugins/node-globals-polyfill": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.2.3.tgz", + "integrity": "sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==", + "peerDependencies": { + "esbuild": "*" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.11.tgz", @@ -50,7 +60,6 @@ "cpu": [ "ppc64" ], - "dev": true, "optional": true, "os": [ "aix" @@ -66,7 +75,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "android" @@ -82,7 +90,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "android" @@ -98,7 +105,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "android" @@ -114,7 +120,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -130,7 +135,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -146,7 +150,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "freebsd" @@ -162,7 +165,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "freebsd" @@ -178,7 +180,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "linux" @@ -194,7 +195,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -210,7 +210,6 @@ "cpu": [ "ia32" ], - "dev": true, "optional": true, "os": [ "linux" @@ -226,7 +225,6 @@ "cpu": [ "loong64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -242,7 +240,6 @@ "cpu": [ "mips64el" ], - "dev": true, "optional": true, "os": [ "linux" @@ -258,7 +255,6 @@ "cpu": [ "ppc64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -274,7 +270,6 @@ "cpu": [ "riscv64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -290,7 +285,6 @@ "cpu": [ "s390x" ], - "dev": true, "optional": true, "os": [ "linux" @@ -306,7 +300,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -322,7 +315,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "netbsd" @@ -338,7 +330,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "openbsd" @@ -354,7 +345,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "sunos" @@ -370,7 +360,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -386,7 +375,6 @@ "cpu": [ "ia32" ], - "dev": true, "optional": true, "os": [ "win32" @@ -402,7 +390,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -1972,7 +1959,6 @@ "version": "0.19.11", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.11.tgz", "integrity": "sha512-HJ96Hev2hX/6i5cDVwcqiJBBtuo9+FeIJOtZ9W1kA5M6AMJRHUZlpYZ1/SbEwtO0ioNAW8rUooVpC/WehY2SfA==", - "dev": true, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" @@ -3211,6 +3197,11 @@ "node": ">= 0.6" } }, + "node_modules/memory-cache": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/memory-cache/-/memory-cache-0.2.0.tgz", + "integrity": "sha512-OcjA+jzjOYzKmKS6IQVALHLVz+rNTMPoJvCztFaZxwG14wtAW7VRZjwTQu06vKCYOxh4jVnik7ya0SXTB0W+xA==" + }, "node_modules/merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", @@ -3329,9 +3320,9 @@ } }, "node_modules/nodemon": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.0.2.tgz", - "integrity": "sha512-9qIN2LNTrEzpOPBaWHTm4Asy1LxXLSickZStAQ4IZe7zsoIpD/A7LWxhZV3t4Zu352uBcqVnRsDXSMR2Sc3lTA==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.0.3.tgz", + "integrity": "sha512-7jH/NXbFPxVaMwmBCC2B9F/V6X1VkEdNgx3iu9jji8WxWcvhMWkmhNWhI5077zknOnZnBzba9hZP6bCPJLSReQ==", "dependencies": { "chokidar": "^3.5.2", "debug": "^4", @@ -4624,165 +4615,148 @@ "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", "dev": true }, + "@esbuild-plugins/node-globals-polyfill": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.2.3.tgz", + "integrity": "sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==", + "requires": {} + }, "@esbuild/aix-ppc64": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.11.tgz", "integrity": "sha512-FnzU0LyE3ySQk7UntJO4+qIiQgI7KoODnZg5xzXIrFJlKd2P2gwHsHY4927xj9y5PJmJSzULiUCWmv7iWnNa7g==", - "dev": true, "optional": true }, "@esbuild/android-arm": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.11.tgz", "integrity": "sha512-5OVapq0ClabvKvQ58Bws8+wkLCV+Rxg7tUVbo9xu034Nm536QTII4YzhaFriQ7rMrorfnFKUsArD2lqKbFY4vw==", - "dev": true, "optional": true }, "@esbuild/android-arm64": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.11.tgz", "integrity": "sha512-aiu7K/5JnLj//KOnOfEZ0D90obUkRzDMyqd/wNAUQ34m4YUPVhRZpnqKV9uqDGxT7cToSDnIHsGooyIczu9T+Q==", - "dev": true, "optional": true }, "@esbuild/android-x64": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.11.tgz", "integrity": "sha512-eccxjlfGw43WYoY9QgB82SgGgDbibcqyDTlk3l3C0jOVHKxrjdc9CTwDUQd0vkvYg5um0OH+GpxYvp39r+IPOg==", - "dev": true, "optional": true }, "@esbuild/darwin-arm64": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.11.tgz", "integrity": "sha512-ETp87DRWuSt9KdDVkqSoKoLFHYTrkyz2+65fj9nfXsaV3bMhTCjtQfw3y+um88vGRKRiF7erPrh/ZuIdLUIVxQ==", - "dev": true, "optional": true }, "@esbuild/darwin-x64": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.11.tgz", "integrity": "sha512-fkFUiS6IUK9WYUO/+22omwetaSNl5/A8giXvQlcinLIjVkxwTLSktbF5f/kJMftM2MJp9+fXqZ5ezS7+SALp4g==", - "dev": true, "optional": true }, "@esbuild/freebsd-arm64": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.11.tgz", "integrity": "sha512-lhoSp5K6bxKRNdXUtHoNc5HhbXVCS8V0iZmDvyWvYq9S5WSfTIHU2UGjcGt7UeS6iEYp9eeymIl5mJBn0yiuxA==", - "dev": true, "optional": true }, "@esbuild/freebsd-x64": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.11.tgz", "integrity": "sha512-JkUqn44AffGXitVI6/AbQdoYAq0TEullFdqcMY/PCUZ36xJ9ZJRtQabzMA+Vi7r78+25ZIBosLTOKnUXBSi1Kw==", - "dev": true, "optional": true }, "@esbuild/linux-arm": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.11.tgz", "integrity": "sha512-3CRkr9+vCV2XJbjwgzjPtO8T0SZUmRZla+UL1jw+XqHZPkPgZiyWvbDvl9rqAN8Zl7qJF0O/9ycMtjU67HN9/Q==", - "dev": true, "optional": true }, "@esbuild/linux-arm64": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.11.tgz", "integrity": "sha512-LneLg3ypEeveBSMuoa0kwMpCGmpu8XQUh+mL8XXwoYZ6Be2qBnVtcDI5azSvh7vioMDhoJFZzp9GWp9IWpYoUg==", - "dev": true, "optional": true }, "@esbuild/linux-ia32": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.11.tgz", "integrity": "sha512-caHy++CsD8Bgq2V5CodbJjFPEiDPq8JJmBdeyZ8GWVQMjRD0sU548nNdwPNvKjVpamYYVL40AORekgfIubwHoA==", - "dev": true, "optional": true }, "@esbuild/linux-loong64": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.11.tgz", "integrity": "sha512-ppZSSLVpPrwHccvC6nQVZaSHlFsvCQyjnvirnVjbKSHuE5N24Yl8F3UwYUUR1UEPaFObGD2tSvVKbvR+uT1Nrg==", - "dev": true, "optional": true }, "@esbuild/linux-mips64el": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.11.tgz", "integrity": "sha512-B5x9j0OgjG+v1dF2DkH34lr+7Gmv0kzX6/V0afF41FkPMMqaQ77pH7CrhWeR22aEeHKaeZVtZ6yFwlxOKPVFyg==", - "dev": true, "optional": true }, "@esbuild/linux-ppc64": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.11.tgz", "integrity": "sha512-MHrZYLeCG8vXblMetWyttkdVRjQlQUb/oMgBNurVEnhj4YWOr4G5lmBfZjHYQHHN0g6yDmCAQRR8MUHldvvRDA==", - "dev": true, "optional": true }, "@esbuild/linux-riscv64": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.11.tgz", "integrity": "sha512-f3DY++t94uVg141dozDu4CCUkYW+09rWtaWfnb3bqe4w5NqmZd6nPVBm+qbz7WaHZCoqXqHz5p6CM6qv3qnSSQ==", - "dev": true, "optional": true }, "@esbuild/linux-s390x": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.11.tgz", "integrity": "sha512-A5xdUoyWJHMMlcSMcPGVLzYzpcY8QP1RtYzX5/bS4dvjBGVxdhuiYyFwp7z74ocV7WDc0n1harxmpq2ePOjI0Q==", - "dev": true, "optional": true }, "@esbuild/linux-x64": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.11.tgz", "integrity": "sha512-grbyMlVCvJSfxFQUndw5mCtWs5LO1gUlwP4CDi4iJBbVpZcqLVT29FxgGuBJGSzyOxotFG4LoO5X+M1350zmPA==", - "dev": true, "optional": true }, "@esbuild/netbsd-x64": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.11.tgz", "integrity": "sha512-13jvrQZJc3P230OhU8xgwUnDeuC/9egsjTkXN49b3GcS5BKvJqZn86aGM8W9pd14Kd+u7HuFBMVtrNGhh6fHEQ==", - "dev": true, "optional": true }, "@esbuild/openbsd-x64": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.11.tgz", "integrity": "sha512-ysyOGZuTp6SNKPE11INDUeFVVQFrhcNDVUgSQVDzqsqX38DjhPEPATpid04LCoUr2WXhQTEZ8ct/EgJCUDpyNw==", - "dev": true, "optional": true }, "@esbuild/sunos-x64": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.11.tgz", "integrity": "sha512-Hf+Sad9nVwvtxy4DXCZQqLpgmRTQqyFyhT3bZ4F2XlJCjxGmRFF0Shwn9rzhOYRB61w9VMXUkxlBy56dk9JJiQ==", - "dev": true, "optional": true }, "@esbuild/win32-arm64": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.11.tgz", "integrity": "sha512-0P58Sbi0LctOMOQbpEOvOL44Ne0sqbS0XWHMvvrg6NE5jQ1xguCSSw9jQeUk2lfrXYsKDdOe6K+oZiwKPilYPQ==", - "dev": true, "optional": true }, "@esbuild/win32-ia32": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.11.tgz", "integrity": "sha512-6YOrWS+sDJDmshdBIQU+Uoyh7pQKrdykdefC1avn76ss5c+RN6gut3LZA4E2cH5xUEp5/cA0+YxRaVtRAb0xBg==", - "dev": true, "optional": true }, "@esbuild/win32-x64": { "version": "0.19.11", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.11.tgz", "integrity": "sha512-vfkhltrjCAb603XaFhqhAF4LGDi2M4OrCRrFusyQ+iTLQ/o60QQXxc9cZC/FFpihBI9N1Grn6SMKVJ4KP7Fuiw==", - "dev": true, "optional": true }, "@eslint-community/eslint-utils": { @@ -5858,7 +5832,6 @@ "version": "0.19.11", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.11.tgz", "integrity": "sha512-HJ96Hev2hX/6i5cDVwcqiJBBtuo9+FeIJOtZ9W1kA5M6AMJRHUZlpYZ1/SbEwtO0ioNAW8rUooVpC/WehY2SfA==", - "dev": true, "requires": { "@esbuild/aix-ppc64": "0.19.11", "@esbuild/android-arm": "0.19.11", @@ -6851,6 +6824,11 @@ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" }, + "memory-cache": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/memory-cache/-/memory-cache-0.2.0.tgz", + "integrity": "sha512-OcjA+jzjOYzKmKS6IQVALHLVz+rNTMPoJvCztFaZxwG14wtAW7VRZjwTQu06vKCYOxh4jVnik7ya0SXTB0W+xA==" + }, "merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", @@ -6927,9 +6905,9 @@ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" }, "nodemon": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.0.2.tgz", - "integrity": "sha512-9qIN2LNTrEzpOPBaWHTm4Asy1LxXLSickZStAQ4IZe7zsoIpD/A7LWxhZV3t4Zu352uBcqVnRsDXSMR2Sc3lTA==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.0.3.tgz", + "integrity": "sha512-7jH/NXbFPxVaMwmBCC2B9F/V6X1VkEdNgx3iu9jji8WxWcvhMWkmhNWhI5077zknOnZnBzba9hZP6bCPJLSReQ==", "requires": { "chokidar": "^3.5.2", "debug": "^4", diff --git a/package.json b/package.json index 6a3ce2cf..2a24e1d3 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,15 @@ "preview": "vite preview" }, "dependencies": { + "@esbuild-plugins/node-globals-polyfill": "^0.2.3", "@statisticsnorway/ssb-component-library": "^2.0.96", "dotenv": "^16.3.1", "express": "4.18.2", "jsonwebtoken": "^9.0.2", "jwks-rsa": "^3.1.0", "lightship": "^9.0.3", - "nodemon": "^3.0.2", + "memory-cache": "^0.2.0", + "nodemon": "^3.0.3", "react": "^18.2.0", "react-dom": "^18.2.0", "react-responsive": "^9.0.2", diff --git a/server.js b/server.js index 1d442b6a..0a8250a6 100644 --- a/server.js +++ b/server.js @@ -1,5 +1,6 @@ import ViteExpress from 'vite-express'; import { createLightship } from 'lightship'; +import cache from 'memory-cache'; import express from 'express'; import jwt from 'jsonwebtoken'; import jwksClient from 'jwks-rsa'; @@ -21,14 +22,14 @@ const client = jwksClient({ app.post('/api/verify-token', (req, res) => { if (!req.headers.authorization.startsWith("Bearer")) { - return res.status(401).json({ success: false, message: 'No token provided' }); + return res.status(401).json({ message: 'No token provided' }); } const token = req.headers.authorization.split("Bearer ")[1]; const decodedToken = jwt.decode(token, { complete: true }); if (!decodedToken) { - return res.status(400).json({ success: false, message: 'Invalid token format' }); + return res.status(400).json({ message: 'Invalid token format' }); } const kid = decodedToken.header.kid; @@ -36,43 +37,115 @@ app.post('/api/verify-token', (req, res) => { .then(publicKey => { jwt.verify(token, publicKey, { algorithms: ['RS256'] }, (err, decoded) => { if (err) { - return res.status(401).json({ success: false, message: 'Invalid token' }); + return res.status(401).json({ message: 'Invalid token' }); } - res.json({ success: true, user: decoded }); + res.json({ user: decoded }); }); }) .catch(error => { console.error(error); - res.status(500).json({ success: false, message: 'Server error', error: error.message }); + res.status(500).json({ message: 'Server error', error: error.message }); }); }); app.get('/api/teams', (req, res) => { if (!req.headers.authorization.startsWith("Bearer")) { - return res.status(401).json({ success: false, message: 'No token provided' }); + return res.status(401).json({ message: 'No token provided' }); } const token = req.headers.authorization.split("Bearer ")[1]; const url = `${process.env.VITE_DAPLA_TEAM_API_URL}/teams`; - fetch(url, { + fetch(url, getFetchOptions(token)) + .then(response => { + if (!response.ok) { + throw new Error('Response not ok'); + } + return response.json(); + }).then(data => { + return res.json(data._embedded.teams); + }).catch(error => { + console.error(error); + res.status(500).json({ message: 'Server error', error: error.message }); + }); +}); + +app.get('/api/userProfile', async (req, res) => { + if (!req.headers.authorization || !req.headers.authorization.startsWith("Bearer")) { + return res.status(401).json({ message: 'No token provided' }); + } + + try { + const token = req.headers.authorization.split("Bearer ")[1]; + const jwt = JSON.parse(atob(token.split('.')[1])); + + const cacheKey = `userProfile-${jwt.email}`; + const cachedUserProfile = cache.get(cacheKey); + if (cachedUserProfile) { + return res.json(cachedUserProfile); + } + + const [userProfile, photo, manager] = await Promise.all([ + fetchUserProfile(token, jwt.email), + fetchPhoto(token, jwt.email), + fetchUserManager(token, jwt.email) + ]); + const data = { ...userProfile, photo: photo, manager: { ...manager } }; + cache.put(cacheKey, data, 3600000); + + return res.json(data); + } catch (error) { + console.error(error); + return res.status(500).json({ message: 'Server error', error: error.message }); + } +}); + +async function fetchUserProfile(token, email) { + const url = `${process.env.VITE_DAPLA_TEAM_API_URL}/users/${email}`; + const response = await fetch(url, getFetchOptions(token)); + + if (!response.ok) { + throw new Error('Failed to fetch user profile'); + } + + return response.json(); +} + +async function fetchUserManager(token) { + const jwt = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()); + const email = jwt.email; + const url = `${process.env.VITE_DAPLA_TEAM_API_URL}/users/${email}/manager`; + const response = await fetch(url, getFetchOptions(token)); + + if (!response.ok) { + throw new Error('Failed to fetch user profile'); + } + + return response.json(); +} + +async function fetchPhoto(token, email) { + const url = `${process.env.VITE_DAPLA_TEAM_API_URL}/users/${email}/photo`; + const response = await fetch(url, getFetchOptions(token)); + + if (!response.ok) { + throw new Error('Failed to fetch photo'); + } + + const arrayBuffer = await response.arrayBuffer(); + const photoBuffer = Buffer.from(arrayBuffer); + return photoBuffer.toString('base64'); +} + +function getFetchOptions(token) { + return { method: "GET", headers: { "accept": "*/*", "Authorization": `Bearer ${token}`, } - }).then(response => { - if (!response.ok) { - throw new Error('Response not ok'); - } - return response.json(); - }).then(data => { - return res.json({ success: true, data: data._embedded.teams }); - }).catch(error => { - console.error(error); - res.status(500).json({ success: false, message: 'Server error', error: error.message }); - }); -}); + }; +} function getPublicKeyFromKeycloak(kid) { return new Promise((resolve, reject) => { diff --git a/src/App.tsx b/src/App.tsx index 6357fd5d..6561c346 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,9 +8,13 @@ import Users from './pages/Users'; import Login from './pages/Login/Login'; import { Routes, Route, useLocation } from 'react-router-dom'; +import { jwtRegex } from './utils/regex'; export default function App() { - const isLoggedIn = useLocation().pathname !== '/login'; + const isLoggedIn = ( + useLocation().pathname !== '/login' && + localStorage.getItem('access_token') !== null && + jwtRegex.test(localStorage.getItem('access_token') as string)); return ( <> @@ -26,8 +30,8 @@ export default function App() { */ } } /> - } /> - Test} /> + } /> + Test} /> diff --git a/src/api/UserApi.ts b/src/api/UserApi.ts new file mode 100644 index 00000000..79686945 --- /dev/null +++ b/src/api/UserApi.ts @@ -0,0 +1,44 @@ + +export interface User { + principalName: string + azureAdId: string + displayName: string + firstName: string + lastName: string + email: string + manager?: User + photo?: string +} + + +export const getUserProfile = async (accessToken: string): Promise => { + return fetch('/api/userProfile', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}` + } + }).then(response => { + if (!response.ok) { + console.error('Request failed with status:', response.status); + throw new Error('Request failed'); + } + return response.json(); + }).then(data => data as User) + .catch(error => { + console.error('Error during fetching userProfile:', error); + throw error; + }); +}; + +export const getUserProfileFallback = (accessToken: string): User => { + const jwt = JSON.parse(atob(accessToken.split('.')[1])); + return { + principalName: jwt.upn, + azureAdId: jwt.oid, // not the real azureAdId, this is actually keycloaks oid + displayName: jwt.name, + firstName: jwt.given_name, + lastName: jwt.family_name, + email: jwt.email + }; +}; \ No newline at end of file diff --git a/src/api/VerifyKeycloakToken.ts b/src/api/VerifyKeycloakToken.ts index 5dce3b83..189363d1 100644 --- a/src/api/VerifyKeycloakToken.ts +++ b/src/api/VerifyKeycloakToken.ts @@ -1,13 +1,12 @@ export const verifyKeycloakToken = (token?: string): Promise => { - const getAccessToken = localStorage.getItem('access_token'); + const accessToken = localStorage.getItem('access_token'); return fetch('/api/verify-token', { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token || getAccessToken}` + 'Authorization': `Bearer ${token || accessToken}` }, - }).then(response => { if (!response.ok) { console.error('Token verification failed with status:', response.status); diff --git a/src/api/teamApi.ts b/src/api/teamApi.ts index 22407525..a17cf5ac 100644 --- a/src/api/teamApi.ts +++ b/src/api/teamApi.ts @@ -1,8 +1,3 @@ -export interface TeamApiResponse { - success: boolean; - data: Team[]; -} - export interface Team { uniformName: string; displayName: string; @@ -18,8 +13,7 @@ interface Link { templated?: boolean; } - -export const getAllTeams = (): Promise => { +export const getAllTeams = (): Promise => { const accessToken = localStorage.getItem('access_token'); return fetch('/api/teams', { @@ -34,7 +28,7 @@ export const getAllTeams = (): Promise => { throw new Error('Request failed'); } return response.json(); - }).then(data => data as TeamApiResponse) + }).then(data => data as Team[]) .catch(error => { console.error('Error during fetching teams:', error); throw error; diff --git a/src/components/Avatar/Avatar.tsx b/src/components/Avatar/Avatar.tsx new file mode 100644 index 00000000..9a7f5700 --- /dev/null +++ b/src/components/Avatar/Avatar.tsx @@ -0,0 +1,26 @@ +import styles from './avatar.module.scss'; +import { useNavigate } from 'react-router-dom'; + +interface PageLayoutProps { + fullName: string, + image?: string, +} + +export default function Avatar({ fullName }: PageLayoutProps) { + const navigate = useNavigate(); + const handleClick = () => { + const path_to_go = encodeURI(`/teammedlemmer/${fullName}`); + navigate(path_to_go); + }; + + const userProfile = JSON.parse(localStorage.getItem('userProfile') || '{}'); + const base64Image = userProfile?.photo; + const imageSrc = base64Image ? `data:image/png;base64,${base64Image}` : null; + + return ( +
+ {imageSrc ? User : +
{`${userProfile.firstName[0]}${userProfile.lastName[0]}`}
} +
+ ); +} diff --git a/src/components/Avatar/avatar.module.scss b/src/components/Avatar/avatar.module.scss new file mode 100644 index 00000000..68fa56c1 --- /dev/null +++ b/src/components/Avatar/avatar.module.scss @@ -0,0 +1,30 @@ +@use '@statisticsnorway/ssb-component-library/src/style/variables' as variables; + +.avatar { + width: 50px; + height: 50px; + margin: 0 1.5rem 0 0; + border: 1px solid variables.$ssb-dark-2; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background-color: #fff; + transition: transform 0.3s; + cursor: pointer; + + .initials { + font-size: 1.5rem; + font-weight: 700; + color: variables.$ssb-dark-5; + } + + img { + width: 50px; + height: 50px; + border-radius: 50%; + object-fit: cover; + border: 2px solid #ffffff; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } +} \ No newline at end of file diff --git a/src/components/Breadcrumb.tsx b/src/components/Breadcrumb.tsx index 562fdaea..7f8b121a 100644 --- a/src/components/Breadcrumb.tsx +++ b/src/components/Breadcrumb.tsx @@ -3,7 +3,7 @@ import { useLocation } from 'react-router-dom'; export default function Breadcrumb() { const location = useLocation(); - const pathnames = location.pathname.split('/').filter(x => x); + const pathnames = location.pathname.split('/').filter(x => x).map(x => decodeURI(x)); const breadcrumbItems = pathnames.map((value, index) => { const last = index === pathnames.length - 1; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index fbba7be0..bc4026d9 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -1,14 +1,33 @@ import styles from './header.module.scss' -import { Link } from 'react-router-dom'; +import { Link } from '@statisticsnorway/ssb-component-library'; +import Avatar from '../Avatar/Avatar'; +import { useNavigate } from 'react-router-dom'; +import { + jwtRegex +} from '../../utils/regex'; -export default function Header(props) { +export default function Header(props: { isLoggedIn: boolean }) { const { isLoggedIn } = props + const token = localStorage.getItem('access_token'); + let decoded_jwt; + + if (token && jwtRegex.test(token)) + decoded_jwt = JSON.parse(atob(token.split('.')[1])); + + const navigate = useNavigate(); return (
- Dapla ctrl - {isLoggedIn && Medlemmer} +

navigate("/")}>Dapla ctrl

+ {isLoggedIn && +
+ Teammedlemmer + {token && } +
+ }
) } \ No newline at end of file diff --git a/src/components/Header/header.module.scss b/src/components/Header/header.module.scss index 8297715b..0fe8c999 100644 --- a/src/components/Header/header.module.scss +++ b/src/components/Header/header.module.scss @@ -1,7 +1,25 @@ +@use '@statisticsnorway/ssb-component-library/src/style/variables' as variables; + .header { display: flex; flex-direction: row; justify-content: space-between; - border-bottom: 1px solid gray; + border-bottom: 1px solid variables.$ssb-dark-2; + color: variables.$ssb-dark-5; + align-items: center; padding: 1rem; + + .navigation { + @extend .header; + gap: 2rem; + border-bottom: 0; // unset border-bottom from the inherited .header class + padding: 0; // unset padding + } + + .title { + @extend .header; + cursor: pointer; + border-bottom: 0; // unset border-bottom from the inherited .header class + padding: 0; // unset padding + } } \ No newline at end of file diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx index 9a434dc7..8fbac708 100644 --- a/src/components/ProtectedRoute.tsx +++ b/src/components/ProtectedRoute.tsx @@ -12,6 +12,7 @@ export const ProtectedRoute = () => { setIsAuthenticated(isValid); if (!isValid) { localStorage.removeItem('access_token'); + localStorage.removeItem('userProfile'); navigate('/login', { state: { from: from } }); } }); diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index f7b1262c..2517b669 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -2,14 +2,14 @@ import PageLayout from '../components/PageLayout/PageLayout' import Table from '../components/Table/Table' import { useEffect, useState } from "react" -import { getAllTeams, TeamApiResponse, Team } from "../api/teamApi" +import { getAllTeams, Team } from "../api/TeamApi" import { Title, Dialog, Link } from "@statisticsnorway/ssb-component-library" export default function Home() { - const [teams, setTeams] = useState(); + const [teams, setTeams] = useState(); const [error, setError] = useState(); - useEffect(() => { + useEffect(() => { getAllTeams().then(response => { setTeams(response); }).catch(error => { @@ -22,7 +22,7 @@ export default function Home() { <> - {team.displayName} + {team.uniformName} {/* TODO: Fetch department from API. Teams are missing a department property */} @@ -39,7 +39,7 @@ export default function Home() { ) } - if (teams && teams.data.length) { + if (teams && teams.length) { const allTeamsTableHeaderColumns = [{ id: 'navn', label: 'Navn', @@ -51,17 +51,17 @@ export default function Home() { id: 'ansvarlig', label: 'Ansvarlig' }] - - const allTeamsTableDataColumns = teams.data.map(team => ({ + + const allTeamsTableDataColumns = teams.map(team => ({ id: team.uniformName, 'navn': renderTeamNameColumn(team), // TODO: Fetch team user count from API e.g. /teams/{team.uniformName}/users and users.count 'teammedlemmer': 12, // TODO: // * Fetch team manager? from API e.g. /groups/{team.uniformName}-managers/users and use users.displayName - 'ansvarlig': 'Lorem ipsum', + 'ansvarlig': 'Lorem ipsum', })) - + // TODO: Loading can be replaced by a spinner eventually return ( <> @@ -70,9 +70,9 @@ export default function Home() { columns={allTeamsTableHeaderColumns} data={allTeamsTableDataColumns} /> - - ) ||

Loading...

|| ( -

No teams found.

+ + ) ||

Loading...

|| ( +

No teams found.

) } } diff --git a/src/pages/Login/Login.tsx b/src/pages/Login/Login.tsx index 9e6ef25d..7346ecc1 100644 --- a/src/pages/Login/Login.tsx +++ b/src/pages/Login/Login.tsx @@ -1,11 +1,11 @@ -import styles from './login.module.scss'; +import styles from './login.module.scss' import { Title, Input, Link } from "@statisticsnorway/ssb-component-library"; import { useEffect, useState } from "react"; import { verifyKeycloakToken } from "../../api/VerifyKeycloakToken"; import { useLocation, useNavigate } from 'react-router-dom'; - -const jwtRegex = /^[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*$/; +import { jwtRegex } from "../../utils/regex"; +import { getUserProfile, getUserProfileFallback } from "../../api/UserApi"; export default function Login() { const [error, setError] = useState(false); @@ -28,31 +28,36 @@ export default function Login() { }, [navigate]); useEffect(() => { - const validateAccessToken = (accessToken: string) => { + const validateToken = async (accessToken: string) => { // Check if the token matches the JWT pattern - if (!jwtRegex.test(accessToken)) { - return Promise.resolve(false); - } + if (!jwtRegex.test(accessToken)) return false; // Check if the token is invalid - return verifyKeycloakToken(accessToken).then(isValid => { - if (!isValid) { - return false; - } - setValue(accessToken); - return true; - }); + const isValid = await verifyKeycloakToken(accessToken); + if (!isValid) return false; + setValue(accessToken); + + try { + const userProfile = await getUserProfile(accessToken); + localStorage.setItem("userProfile", JSON.stringify(userProfile)); + } catch (error) { + console.error("Could not fetch user profile, using fallback", error); + const userProfile = getUserProfileFallback(accessToken); + localStorage.setItem("userProfile", JSON.stringify(userProfile)); + } + + return true; }; if (!value) { setError(false); } else { - validateAccessToken(value).then(isValidToken => { - if (isValidToken) { - localStorage.setItem('access_token', value) + validateToken(value).then(isValidAccessToken => { + if (isValidAccessToken) { + localStorage.setItem("access_token", value); navigate(from); } - setError(!isValidToken); + setError(!isValidAccessToken); }); } }, [value, from]); @@ -69,11 +74,10 @@ export default function Login() { ) diff --git a/src/pages/Users.tsx b/src/pages/Users.tsx index fa0733a7..5c911af2 100644 --- a/src/pages/Users.tsx +++ b/src/pages/Users.tsx @@ -1,10 +1,10 @@ import PageLayout from "../components/PageLayout/PageLayout" -import { getAllTeams, TeamApiResponse } from "../api/teamApi" +import { getAllTeams, Team } from "../api/TeamApi" import { useEffect, useState } from "react" import { Dialog } from "@statisticsnorway/ssb-component-library" export default function Users() { - const [teams, setTeams] = useState(); + const [teams, setTeams] = useState(); const [error, setError] = useState(); useEffect(() => { @@ -18,11 +18,11 @@ export default function Users() { return ( <> {error ? {error} - : teams && teams.data.length > 0 && ( + : teams && teams.length > 0 && ( <>

Team List

@@ -33,7 +33,7 @@ export default function Users() { - {teams.data.map(team => ( + {teams.map(team => ( diff --git a/src/utils/regex.ts b/src/utils/regex.ts new file mode 100644 index 00000000..0be8b190 --- /dev/null +++ b/src/utils/regex.ts @@ -0,0 +1,2 @@ + +export const jwtRegex = /^[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*$/; \ No newline at end of file
{team.uniformName} {team.displayName}