diff --git a/package-lock.json b/package-lock.json index fdaa17a4..0d599ce1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "eslint-plugin-prettier": "^5.1.3", "express": "4.18.3", "express-http-proxy": "^2.0.0", + "http-proxy-middleware": "^2.0.6", "http-status-codes": "^2.3.0", "jsonwebtoken": "^9.0.2", "jwks-rsa": "^3.1.0", @@ -1679,6 +1680,14 @@ "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" }, + "node_modules/@types/http-proxy": { + "version": "1.17.14", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.14.tgz", + "integrity": "sha512-SSrD0c1OQzlFX7pGu1eXxSEjemej64aaNPRhhVYUGqXh0BtldAAx37MG8btcumvpgKyZp1F5Gn3JkktdxiFv6w==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2939,6 +2948,11 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -3280,6 +3294,25 @@ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==" }, + "node_modules/follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3515,6 +3548,42 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-middleware": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", + "integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==", + "dependencies": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@types/express": "^4.17.13" + }, + "peerDependenciesMeta": { + "@types/express": { + "optional": true + } + } + }, "node_modules/http-status-codes": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", @@ -3680,6 +3749,17 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4003,7 +4083,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", - "dev": true, "dependencies": { "braces": "^3.0.2", "picomatch": "^2.3.1" @@ -4727,6 +4806,11 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -6491,6 +6575,14 @@ "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" }, + "@types/http-proxy": { + "version": "1.17.14", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.14.tgz", + "integrity": "sha512-SSrD0c1OQzlFX7pGu1eXxSEjemej64aaNPRhhVYUGqXh0BtldAAx37MG8btcumvpgKyZp1F5Gn3JkktdxiFv6w==", + "requires": { + "@types/node": "*" + } + }, "@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -7383,6 +7475,11 @@ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" }, + "eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, "events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -7688,6 +7785,11 @@ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==" }, + "follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==" + }, "forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -7852,6 +7954,28 @@ "toidentifier": "1.0.1" } }, + "http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "requires": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + } + }, + "http-proxy-middleware": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", + "integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==", + "requires": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + } + }, "http-status-codes": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", @@ -7967,6 +8091,11 @@ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==" }, + "is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==" + }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -8254,7 +8383,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", - "dev": true, "requires": { "braces": "^3.0.2", "picomatch": "^2.3.1" @@ -8742,6 +8870,11 @@ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" }, + "requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + }, "resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", diff --git a/package.json b/package.json index c4e936eb..8a0a70a3 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "eslint-plugin-prettier": "^5.1.3", "express": "4.18.3", "express-http-proxy": "^2.0.0", + "http-proxy-middleware": "^2.0.6", "http-status-codes": "^2.3.0", "jsonwebtoken": "^9.0.2", "jwks-rsa": "^3.1.0", diff --git a/server.js b/server.js index d28f5bc2..1e4129d2 100644 --- a/server.js +++ b/server.js @@ -13,10 +13,13 @@ const app = express() const PORT = process.env.PORT || 3000 const DAPLA_TEAM_API_URL = process.env.DAPLA_TEAM_API_URL || 'https://dapla-team-api-v2.staging-bip-app.ssb.no' -// Proxy, note this middleware must be place before all else.. THIS TOOK ME 3 HOURS TO FIGURE OUT! TODO: Remove comment app.use( '/api', proxy(DAPLA_TEAM_API_URL, { + proxyReqBodyDecorator: function (bodyContent, srcReq) { + console.log(`Request Body: ${bodyContent}`) + return bodyContent + }, proxyReqOptDecorator: function (proxyReqOpts, srcReq) { console.log(`Request Headers:`, srcReq.headers) if (srcReq.body) { @@ -34,6 +37,10 @@ app.use( console.log(`Response Headers:`, proxyRes.headers) return proxyResData }, + proxyErrorHandler: function (err, res) { + console.error('Proxy Error:', err) + res.status(500).send('Proxy Error') + }, }) ) @@ -66,6 +73,39 @@ async function fetchPhoto(accessToken, url, fallbackErrorMessage) { return photoBuffer.toString('base64') } +//TODO: Remove me once DELETE with proxy is fixed +app.delete('/localApi/groups/:groupUniformName/:userPrincipalName', async (req, res) => { + const token = req.headers.authorization + const groupUniformName = req.params.groupUniformName + const userPrincipalName = req.params.userPrincipalName + const groupsUrl = `${DAPLA_TEAM_API_URL}/groups/${groupUniformName}/users` + + try { + const response = await fetch(groupsUrl, { + method: 'DELETE', + headers: { + Accept: '*/*', + 'Content-Type': 'application/json', + Authorization: token, + }, + body: JSON.stringify({ + users: [userPrincipalName], + }), + }) + + if (!response.ok) { + const err = await response.text() + res.status(response.status).send(err) + } else { + const data = await response.json() + res.status(response.status).send(data) + } + } catch (error) { + console.log(error) + res.status(500).send('Internal Server Error') + } +}) + app.get('/localApi/fetch-token', (req, res) => { if (!req.headers.authorization || !req.headers.authorization.startsWith('Bearer')) { return res.status(401).json({ message: 'No token provided' }) diff --git a/src/components/DeleteLink/DeleteLink.tsx b/src/components/DeleteLink/DeleteLink.tsx new file mode 100644 index 00000000..a6c0f5ec --- /dev/null +++ b/src/components/DeleteLink/DeleteLink.tsx @@ -0,0 +1,21 @@ +import styles from './deletelink.module.scss' + +import { Trash2 } from 'react-feather' + +interface DeleteLink { + children: string + tabIndex?: number + icon?: boolean + handleDeleteUser: CallableFunction +} + +const DeleteLink = ({ children, tabIndex, icon, handleDeleteUser }: DeleteLink) => { + return ( + handleDeleteUser}> + {icon && } + {children} + + ) +} + +export default DeleteLink diff --git a/src/components/DeleteLink/deletelink.module.scss b/src/components/DeleteLink/deletelink.module.scss new file mode 100644 index 00000000..7ff82185 --- /dev/null +++ b/src/components/DeleteLink/deletelink.module.scss @@ -0,0 +1,22 @@ +@use '@statisticsnorway/ssb-component-library/src/style/variables' as variables; + +.deleteLinkWrapper { + display: inline-flex; + align-items: center; + color: variables.$ssb-red-3; + padding: .5rem; + line-height: 1.7; + cursor: pointer; + + svg { + margin-right: .5rem; + } + + span { + border-bottom: 1px solid; + } + + &:focus { + @include variables.focus-marker; + } +} \ No newline at end of file diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 921baa00..b022e732 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -14,11 +14,13 @@ interface TableProps extends TableData { interface TableDesktopViewProps extends TableData { activeTab?: string } + export interface TableData { columns: { id: string label: string unsortable?: boolean + align?: string }[] data: { id: string @@ -152,7 +154,9 @@ const TableDesktopView = ({ columns, data, activeTab }: TableDesktopViewProps) = return ( {columns.map((column) => ( - {row[column.id]} + + {row[column.id]} + ))} ) diff --git a/src/components/Table/table.module.scss b/src/components/Table/table.module.scss index e66ad532..830d20a1 100644 --- a/src/components/Table/table.module.scss +++ b/src/components/Table/table.module.scss @@ -134,4 +134,11 @@ @media #{variables.$mobile} { display: flex; } +} + +.centerText { + span { + display: flex; + justify-content: center; + } } \ No newline at end of file diff --git a/src/pages/TeamDetail/TeamDetail.tsx b/src/pages/TeamDetail/TeamDetail.tsx index 4dd57ab3..476268f6 100644 --- a/src/pages/TeamDetail/TeamDetail.tsx +++ b/src/pages/TeamDetail/TeamDetail.tsx @@ -4,9 +4,18 @@ import styles from './teamDetail.module.scss' import { DropdownItems, TabProps } from '../../@types/pageTypes' -import { useCallback, useContext, useEffect, useState } from 'react' +import { ReactElement, useCallback, useContext, useEffect, useState } from 'react' import PageLayout from '../../components/PageLayout/PageLayout' -import { TeamDetailData, getTeamDetail, Team, SharedBuckets, addUserToGroups } from '../../services/teamDetail' +import { + TeamDetailData, + getTeamDetail, + Team, + SharedBuckets, + addUserToGroups, + removeUserFromGroups, + Group, + JobResponse, +} from '../../services/teamDetail' import { useParams } from 'react-router-dom' import { ApiError, TokenData, fetchUserInformationFromAuthToken } from '../../utils/services' @@ -23,12 +32,20 @@ import { Input, Dropdown, Tag, + Link, } from '@statisticsnorway/ssb-component-library' import PageSkeleton from '../../components/PageSkeleton/PageSkeleton' import { Skeleton, CircularProgress } from '@mui/material' import { XCircle } from 'react-feather' import FormattedTableColumn from '../../components/FormattedTableColumn' import SidebarModal from '../../components/SidebarModal/SidebarModal' +import DeleteLink from '../../components/DeleteLink/DeleteLink' + +interface UserInfo { + name?: string + email?: string + groups?: Group[] +} const TEAM_USERS_TAB = { title: 'Teammedlemmer', @@ -73,8 +90,9 @@ const defaultEmail = { value: '', } -const defaultSelectedItem = { - key: 'add-user-selected-group', +const defaultAddUserKey = 'add-user-selected-group' +const defaultEditUserKey = 'edit-user-selected-group' +const defaultSelectedGroup = { id: 'velg', title: 'Velg ...', } @@ -93,16 +111,31 @@ const TeamDetail = () => { ) const [teamDetailTableData, setTeamDetailTableData] = useState() - const [openSidebar, setOpenSidebar] = useState(false) + // Add users to team + const [openAddUserSidebarModal, setOpenAddUserSidebarModal] = useState(false) const [email, setEmail] = useState(defaultEmail) - const [selectedItem, setSelectedItem] = useState(defaultSelectedItem) + const [selectedGroupAddUser, setSelectedGroupAddUser] = useState({ + ...defaultSelectedGroup, + key: defaultAddUserKey, + }) const [teamGroupTags, setTeamGroupTags] = useState([]) const [teamGroupTagsError, setTeamGroupTagsError] = useState({ error: false, errorMessage: 'Velg minst én tilgangsgruppe', }) const [addUserToTeamErrors, setAddUserToTeamErrors] = useState>([]) - const [showSpinner, setShowSpinner] = useState(false) + const [showAddUserSpinner, setShowAddUserSpinner] = useState(false) + + // Edit users in team + const [openEditUserSidebarModal, setOpenEditUserSidebarModal] = useState(false) + const [editUserInfo, setEditUserInfo] = useState({ name: '', email: '', groups: [] }) + const [selectedGroupEditUser, setSelectedGroupEditUser] = useState({ + ...defaultSelectedGroup, + key: defaultEditUserKey, + }) + const [userGroupTags, setUserGroupTags] = useState([]) + const [editUserErrors, setEditUserErrors] = useState>([]) + const [showEditUserSpinner, setShowEditUserSpinner] = useState(false) const { teamId } = useParams<{ teamId: string }>() const teamDetailTab = (activeTab as TabProps)?.path ?? activeTab @@ -129,8 +162,12 @@ const TeamDetail = () => { if (!teamUsers) return [] return teamUsers.map(({ display_name, principal_name, section_name, groups }) => { + const userFullName = formatDisplayName(display_name) + const userGroups = groups?.filter((group) => + group.uniform_name.startsWith((response.team as Team).uniform_name) + ) as Group[] return { - id: formatDisplayName(display_name), + id: userFullName, navn: ( { .map((group) => getGroupType(group.uniform_name)) .join(', '), epost: principal_name, + editUser: ( + + { + setOpenEditUserSidebarModal(true) + setEditUserInfo({ + name: formatDisplayName(display_name), + email: principal_name, + groups: userGroups, + }) + setUserGroupTags( + userGroups.map(({ uniform_name }) => { + return { id: uniform_name, title: getGroupType(uniform_name) } + }) + ) + }} + > + Endre + + + ), } }) } @@ -159,6 +217,11 @@ const TeamDetail = () => { [activeTab] ) + const isTeamManager = useCallback(() => { + const teamManagers = (teamDetailData && (teamDetailData.team as Team).managers) ?? [] + return teamManagers?.some((manager) => manager.principal_name === tokenData?.email) + }, [tokenData, teamDetailData]) + useEffect(() => { if (!teamId) return fetchUserInformationFromAuthToken() @@ -179,6 +242,20 @@ const TeamDetail = () => { }) }, []) + useEffect(() => { + if (isTeamManager()) { + setTeamDetailTableHeaderColumns([ + ...TEAM_USERS_TAB.columns, + { + id: 'editUser', + label: '', + unsortable: true, + align: 'center', + }, + ]) + } + }, [isTeamManager]) + useEffect(() => { if (teamDetailData) { if (teamDetailTab === SHARED_BUCKETS_TAB.path) { @@ -186,7 +263,19 @@ const TeamDetail = () => { setTeamDetailTableHeaderColumns(SHARED_BUCKETS_TAB.columns) } else { setTeamDetailTableTitle(TEAM_USERS_TAB.title) - setTeamDetailTableHeaderColumns(TEAM_USERS_TAB.columns) + if (isTeamManager()) { + setTeamDetailTableHeaderColumns([ + ...TEAM_USERS_TAB.columns, + { + id: 'editUser', + label: '', + unsortable: true, + align: 'center', + }, + ]) + } else { + setTeamDetailTableHeaderColumns(TEAM_USERS_TAB.columns) + } } setTeamDetailTableData(prepTeamData(teamDetailData)) } @@ -241,29 +330,52 @@ const TeamDetail = () => { } } - const handleAddTeamGroupTag = (item: DropdownItems) => { - const teamGroupsTags = [...teamGroupTags, item].reduce((acc: DropdownItems[], dropdownItem: DropdownItems) => { + const removeDuplicateDropdownItems = (items: DropdownItems[]) => { + return items.reduce((acc: DropdownItems[], dropdownItem: DropdownItems) => { const ids = acc.map((obj) => obj.id) if (!ids.includes(dropdownItem.id)) { acc.push(dropdownItem) } return acc }, []) - setTeamGroupTags(teamGroupsTags) - setTeamGroupTagsError({ ...teamGroupTagsError, error: false }) - setSelectedItem({ ...item, key: `${selectedItem.key}-${item.id}` }) + } + + const handleAddGroupTag = (item: DropdownItems) => { + if (openAddUserSidebarModal) { + const teamGroupsTags = removeDuplicateDropdownItems([...teamGroupTags, item]) + setTeamGroupTags(teamGroupsTags) + setTeamGroupTagsError({ ...teamGroupTagsError, error: false }) + setSelectedGroupAddUser({ ...item, key: `${defaultAddUserKey}-${item.id}` }) + } + + if (openEditUserSidebarModal) { + const userGroupsTagsList = removeDuplicateDropdownItems([...userGroupTags, item]) + setUserGroupTags(userGroupsTagsList) + setSelectedGroupEditUser({ ...item, key: `${defaultEditUserKey}-${item.id}` }) + } } const handleDeleteGroupTag = (item: DropdownItems) => { - const teamGroupsTags = teamGroupTags.filter((items) => items !== item) - setTeamGroupTags(teamGroupsTags) + if (openAddUserSidebarModal) { + const teamGroupsTags = teamGroupTags.filter((items) => items !== item) + setTeamGroupTags(teamGroupsTags) + } + + if (openEditUserSidebarModal) { + const userGroupsTags = userGroupTags.filter((items) => items !== item) + setUserGroupTags(userGroupsTags) + } } - const isUserInputValid = (value?: string) => { - const regEx = /^[\w-]+@ssb\.no$/ - const userVal = value || email.value - const testUser = userVal.match(regEx) - return !!testUser + const getErrorList = (response: JobResponse[]) => { + return response + .map(({ status, detail }) => { + if ((detail && status === 'ERROR') || (detail && status === 'IGNORED')) { + return detail + } + return '' + }) + .filter((str) => str !== '') } const handleAddUserOnSubmit = () => { @@ -277,70 +389,181 @@ const TeamDetail = () => { if (email.value !== '' && teamGroupTags.length) { setEmail({ ...email, key: `add-user-${email.value}` }) setAddUserToTeamErrors([]) - setShowSpinner(true) + setShowAddUserSpinner(true) addUserToGroups( teamGroupTags.map((group) => group.id), email.value ) .then((response) => { - const errorsList = response - .map(({ status, detail }) => { - if ((detail && status === 'ERROR') || (detail && status === 'IGNORED')) { - return detail - } - return '' - }) - .filter((str) => str !== '') - + const errorsList = getErrorList(response) if (errorsList.length) { setAddUserToTeamErrors(errorsList) } else { - setOpenSidebar(false) + setOpenAddUserSidebarModal(false) setTeamGroupTags([]) // Reset fields with their respective keys; re-initializes component setEmail({ ...defaultEmail }) - setSelectedItem({ ...defaultSelectedItem }) + setSelectedGroupAddUser({ ...defaultSelectedGroup, key: defaultAddUserKey }) } }) .catch((e) => setAddUserToTeamErrors(e.message)) - .finally(() => setShowSpinner(false)) + .finally(() => setShowAddUserSpinner(false)) + } + } + + const handleEditUserOnSubmit = () => { + const addedGroups = + userGroupTags?.filter((groupTag) => !editUserInfo.groups?.some((group) => groupTag.id === group.uniform_name)) ?? + [] + const removedGroups = + editUserInfo.groups?.filter((group) => !userGroupTags?.some((groupTag) => groupTag.id === group.uniform_name)) ?? + [] + if (addedGroups.length && removedGroups.length) { + setEditUserErrors([]) + setShowEditUserSpinner(true) + Promise.all([ + addUserToGroups( + addedGroups.map((group) => group.id), + editUserInfo?.email as string + ), + removeUserFromGroups( + removedGroups.map((group) => group.uniform_name), + editUserInfo?.email as string + ), + ]) + .then((response) => { + const flattenedResponse = [...response[0], ...response[1]] + const errorsList = getErrorList(flattenedResponse) + if (errorsList.length) { + setEditUserErrors(errorsList) + } else { + setOpenEditUserSidebarModal(false) + // Reset fields with their respective keys; re-initializes component + setSelectedGroupEditUser({ ...defaultSelectedGroup, key: defaultEditUserKey }) + } + }) + .catch((e) => setEditUserErrors(e.message)) + .finally(() => setShowEditUserSpinner(false)) + return + } + + if (removedGroups.length) { + setEditUserErrors([]) + setShowEditUserSpinner(true) + removeUserFromGroups( + removedGroups?.map((group) => group.uniform_name), + editUserInfo.email as string + ) + .then((response) => { + const errorsList = getErrorList(response) + if (errorsList.length) { + setEditUserErrors(errorsList) + } else { + setOpenEditUserSidebarModal(false) + // Reset fields with their respective keys; re-initializes component + setSelectedGroupEditUser({ ...defaultSelectedGroup, key: defaultEditUserKey }) + } + }) + .catch((e) => setEditUserErrors(e.message)) + .finally(() => setShowEditUserSpinner(false)) + return + } + + if (addedGroups.length) { + setEditUserErrors([]) + setShowEditUserSpinner(true) + addUserToGroups( + addedGroups.map((group) => group.id), + editUserInfo?.email as string + ) + .then((response) => { + const errorsList = getErrorList(response) + if (errorsList.length) { + setEditUserErrors(errorsList) + } else { + setOpenEditUserSidebarModal(false) + // Reset fields with their respective keys; re-initializes component + setSelectedGroupEditUser({ ...defaultSelectedGroup, key: defaultEditUserKey }) + } + }) + .catch((e) => setEditUserErrors(e.message)) + .finally(() => setShowEditUserSpinner(false)) + return + } + } + + const handleDeleteUser = () => { + if (editUserInfo.groups && editUserInfo.groups.length) { + setEditUserErrors([]) + setShowEditUserSpinner(true) + removeUserFromGroups( + editUserInfo.groups.map(({ uniform_name }) => uniform_name), + editUserInfo.email as string + ) + .then((response) => { + const errorsList = getErrorList(response) + if (errorsList.length) { + setEditUserErrors(errorsList) + } else { + setOpenEditUserSidebarModal(false) + // Reset fields with their respective keys; re-initializes component + setSelectedGroupEditUser({ ...defaultSelectedGroup, key: defaultEditUserKey }) + } + }) + .catch((e) => setEditUserErrors(e.message)) + .finally(() => setShowEditUserSpinner(false)) } } - const renderSidebarModalAlert = () => { + const renderSidebarModalInfo = (children: ReactElement) => { return (
Det kan ta opp til 45 minutter før personen kan bruke tilgangen - {addUserToTeamErrors.length ? ( - - {typeof addUserToTeamErrors === 'string' ? ( - addUserToTeamErrors - ) : ( -
    - {addUserToTeamErrors.map((errors) => ( -
  • {errors}
  • - ))} -
- )} -
- ) : null} - {showSpinner && } + {children}
) } - const renderSidebarModal = () => { + const renderSidebarModalWarning = (errorList: string[]) => { + return ( + + {typeof errorList === 'string' ? ( + errorList + ) : ( +
    + {errorList.map((errors) => ( +
  • {errors}
  • + ))} +
+ )} +
+ ) + } + + const isUserInputValid = (value?: string) => { + const regEx = /^[\w-]+@ssb\.no$/ + const userVal = value || email.value + const testUser = userVal.match(regEx) + return !!testUser + } + + const teamModalHeader = teamDetailData + ? { + modalType: 'Medlem', + modalTitle: `${(teamDetailData?.team as Team).display_name}`, + modalDescription: `${(teamDetailData?.team as Team).uniform_name}`, + } + : { + modalTitle: '', + } + const teamGroups = teamDetailData ? ((teamDetailData.team as Team).groups as Group[]) : [] + const renderAddUserSidebarModal = () => { if (teamDetailData) { - const teamGroups = (teamDetailData?.team as Team).groups ?? [] return ( setOpenSidebar(false)} - header={{ - modalType: 'Medlem', - modalTitle: `${(teamDetailData?.team as Team).display_name}`, - modalDescription: `${(teamDetailData?.team as Team).uniform_name}`, - }} + open={openAddUserSidebarModal} + onClose={() => setOpenAddUserSidebarModal(false)} + header={teamModalHeader} footer={{ submitButtonText: 'Legg til medlem', handleSubmit: handleAddUserOnSubmit, @@ -371,15 +594,15 @@ const TeamDetail = () => { } /> ({ id: uniform_name, title: getGroupType(uniform_name), }))} - onSelect={handleAddTeamGroupTag} + onSelect={(item: DropdownItems) => handleAddGroupTag(item)} error={teamGroupTagsError.error} errorMessage={teamGroupTagsError.errorMessage} /> @@ -395,7 +618,71 @@ const TeamDetail = () => { ))} - {renderSidebarModalAlert()} +
+ {renderSidebarModalInfo( + <> + {addUserToTeamErrors.length ? renderSidebarModalWarning(addUserToTeamErrors) : null} + {showAddUserSpinner && } + + )} +
+ + ), + }} + /> + ) + } + } + + const renderEditUserSidebarModal = () => { + if (teamDetailData && editUserInfo) { + return ( + setOpenEditUserSidebarModal(false)} + header={teamModalHeader} + footer={{ + submitButtonText: 'Oppdater Tilgang', + handleSubmit: handleEditUserOnSubmit, + }} + body={{ + modalBodyTitle: `Endre tilgang til "${editUserInfo.name}"`, + modalBody: ( + <> + ({ + id: uniform_name, + title: getGroupType(uniform_name), + }))} + onSelect={(item: DropdownItems) => handleAddGroupTag(item)} + /> +
+ {userGroupTags && + userGroupTags.map((group) => ( + } + onClick={() => handleDeleteGroupTag(group)} + > + {group.title} + + ))} +
+
+ + Fjern fra teamet + + {renderSidebarModalInfo( + <> + {editUserErrors.length ? renderSidebarModalWarning(editUserErrors) : null} + {showEditUserSpinner && } + + )} +
), }} @@ -404,10 +691,10 @@ const TeamDetail = () => { } } - const teamManager = teamDetailData ? (teamDetailData?.team as Team).managers : [] return ( <> - {renderSidebarModal()} + {renderAddUserSidebarModal()} + {renderEditUserSidebarModal()} { } content={renderContent()} button={ - teamManager?.some((manager) => manager.principal_name === tokenData?.email) ? ( - - ) : undefined + isTeamManager() ? : undefined } /> diff --git a/src/services/teamDetail.ts b/src/services/teamDetail.ts index d11e6518..5450809a 100644 --- a/src/services/teamDetail.ts +++ b/src/services/teamDetail.ts @@ -34,7 +34,7 @@ export interface User { groups?: Group[] } -interface Group { +export interface Group { uniform_name: string display_name: string } @@ -62,6 +62,8 @@ export interface JobResponse { detail?: string } +type Method = 'POST' | 'DELETE' // POST = ADD, DELETE = REMOVE + export const fetchTeamInfo = async (teamId: string): Promise => { const teamsUrl = new URL(`${TEAMS_URL}/${teamId}`, window.location.origin) const embeds = ['users', 'users.groups', 'managers', 'groups'] @@ -196,7 +198,9 @@ export const getTeamDetail = async (teamId: string): Promise => export const addUserToGroups = async (groupIds: string[], userPrincipalName: string): Promise => { try { - const jobResponses = await Promise.all(groupIds.map((groupId) => addUserToGroup(groupId, userPrincipalName))) + const jobResponses = await Promise.all( + groupIds.map((groupId) => updateGroupMembership(groupId, userPrincipalName, 'POST')) + ) return jobResponses } catch (error) { if (error instanceof ApiError) { @@ -210,18 +214,50 @@ export const addUserToGroups = async (groupIds: string[], userPrincipalName: str } } -const addUserToGroup = async (groupId: string, userPrincipalName: string): Promise => { - const groupsUrl = `${GROUPS_URL}/${groupId}/users` +export const removeUserFromGroups = async (groupIds: string[], userPrincipalName: string): Promise => { try { - const response = await fetch(groupsUrl, { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ - users: [userPrincipalName], - }), - }) + const jobResponses = await Promise.all( + groupIds.map((groupId) => updateGroupMembership(groupId, userPrincipalName, 'DELETE')) + ) + return jobResponses + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to remove user from groups: ', error) + throw error + } else { + const apiError = new ApiError(500, 'An unexpected error occurred') + console.error('Failed to remove user from groups: ', apiError) + throw apiError + } + } +} + +const updateGroupMembership = async ( + groupId: string, + userPrincipalName: string, + method: Method +): Promise => { + let groupsUrl = `${GROUPS_URL}/${groupId}/users` + const fetchOptions: RequestInit = { + method: method, + headers: { + Accept: '*/*', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + users: [userPrincipalName], + }), + } + + // TODO: Remove me once DELETE with proxy is fixed + if (method === 'DELETE') { + groupsUrl = `/localApi/groups/${groupId}/${userPrincipalName}` + // Don't include body in fetch options for DELETE method + delete fetchOptions.body + } + + try { + const response = await fetch(groupsUrl, fetchOptions) if (!response.ok) { const errorMessage = (await response.text()) || 'An error occurred' @@ -230,16 +266,16 @@ const addUserToGroup = async (groupId: string, userPrincipalName: string): Promi } const responseJson = await response.json() - const flattendResponse = { ...responseJson._embedded.results[0] } + const flattenedResponse = { ...responseJson._embedded.results[0] } - return flattendResponse + return flattenedResponse } catch (error) { if (error instanceof ApiError) { - console.error('Failed to add user to group: ', error) + console.error('Failed to update group membership: ', error) throw error } else { const apiError = new ApiError(500, 'An unexpected error occurred') - console.error('Failed to add user to group: ', apiError) + console.error('Failed to update group membership: ', apiError) throw apiError } }