From 39175564fef9f12f4199cef6151117158298eaac Mon Sep 17 00:00:00 2001 From: "j.dev" Date: Thu, 14 Nov 2024 19:48:30 -0800 Subject: [PATCH 1/2] feat(3972): add admin users page --- .../api/keycloak/users/_operations/list.ts | 10 ++ app/app/api/keycloak/users/route.ts | 11 ++ app/app/api/msgraph/search/route.ts | 59 +++++++++ app/app/api/users/search/route.ts | 96 ++++++++------- app/app/users/all/FilterPanel.tsx | 55 +++++++++ app/app/users/all/layout.tsx | 5 + app/app/users/all/page.tsx | 63 ++++++++++ app/app/users/all/state.ts | 16 +++ .../generic/select/FormSingleSelect.tsx | 3 +- app/components/layouts/UserMenu.tsx | 7 ++ app/components/table/TableBodyUsers.tsx | 113 ++++++++++++++++++ app/components/users/UserAutocomplete.tsx | 4 +- app/constants/user.ts | 37 ++++++ app/core/auth-options.ts | 1 + app/services/backend/{users.ts => msgraph.ts} | 4 +- app/services/backend/user.ts | 34 ++++++ app/services/db/user.ts | 72 +++++++++++ app/services/keycloak/app-realm.ts | 75 ++++++++++++ app/types/next-auth.d.ts | 2 + app/types/user.ts | 8 ++ app/validation-schemas/index.ts | 6 + app/validation-schemas/user.ts | 16 +++ 22 files changed, 646 insertions(+), 51 deletions(-) create mode 100644 app/app/api/keycloak/users/_operations/list.ts create mode 100644 app/app/api/keycloak/users/route.ts create mode 100644 app/app/api/msgraph/search/route.ts create mode 100644 app/app/users/all/FilterPanel.tsx create mode 100644 app/app/users/all/layout.tsx create mode 100644 app/app/users/all/page.tsx create mode 100644 app/app/users/all/state.ts create mode 100644 app/components/table/TableBodyUsers.tsx rename app/services/backend/{users.ts => msgraph.ts} (76%) create mode 100644 app/services/backend/user.ts create mode 100644 app/validation-schemas/index.ts create mode 100644 app/validation-schemas/user.ts diff --git a/app/app/api/keycloak/users/_operations/list.ts b/app/app/api/keycloak/users/_operations/list.ts new file mode 100644 index 000000000..95dfb65c9 --- /dev/null +++ b/app/app/api/keycloak/users/_operations/list.ts @@ -0,0 +1,10 @@ +import { Session } from 'next-auth'; +import { AUTH_RESOURCE } from '@/config'; +import { OkResponse, BadRequestResponse } from '@/core/responses'; +import { listUsers, listUsersByRole } from '@/services/keycloak/app-realm'; + +export default async function listOp({ session }: { session: Session }) { + const roles = await listUsersByRole('a'); + + return OkResponse(roles); +} diff --git a/app/app/api/keycloak/users/route.ts b/app/app/api/keycloak/users/route.ts new file mode 100644 index 000000000..d71f4cf66 --- /dev/null +++ b/app/app/api/keycloak/users/route.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; +import { GlobalRole } from '@/constants'; +import createApiHandler from '@/core/api-handler'; +import listOp from './_operations/list'; + +export const GET = createApiHandler({ + // roles: [GlobalRole.Admin], +})(async ({ session }) => { + const res = await listOp({ session }); + return res; +}); diff --git a/app/app/api/msgraph/search/route.ts b/app/app/api/msgraph/search/route.ts new file mode 100644 index 000000000..9a3f59375 --- /dev/null +++ b/app/app/api/msgraph/search/route.ts @@ -0,0 +1,59 @@ +import _isString from 'lodash-es/isString'; +import { string, z } from 'zod'; +import { GlobalRole } from '@/constants'; +import createApiHandler from '@/core/api-handler'; +import prisma from '@/core/prisma'; +import { OkResponse } from '@/core/responses'; +import { prepareUserData } from '@/services/db'; +import { listUsersByEmail } from '@/services/msgraph'; + +const userSearchBodySchema = z.object({ + email: z.string().max(40), +}); + +export const POST = createApiHandler({ + roles: [GlobalRole.User], + validations: { body: userSearchBodySchema }, +})(async ({ session, body }) => { + const { email } = body; + if (email.length < 3) { + return OkResponse({ data: [], totalCount: 0 }); + } + + const users = await listUsersByEmail(email); + + const dbUsers = await Promise.all( + users.map(async (user) => { + const data = await prepareUserData(user); + // The upsert method returns { count: x } when updating data instead of the document. + // Related issue: https://github.com/prisma/prisma/issues/10935 + await prisma.user.upsert({ + where: { email: data.email }, + update: data, + create: data, + }); + + return prisma.user.findUnique({ + where: { email: data.email }, + select: { + id: true, + firstName: true, + lastName: true, + email: true, + upn: true, + idir: true, + officeLocation: true, + jobTitle: true, + image: true, + ministry: true, + archived: true, + createdAt: true, + updatedAt: true, + lastSeen: true, + }, + }); + }), + ); + + return OkResponse({ data: dbUsers, totalCount: users.length }); +}); diff --git a/app/app/api/users/search/route.ts b/app/app/api/users/search/route.ts index 9a3f59375..8f389f409 100644 --- a/app/app/api/users/search/route.ts +++ b/app/app/api/users/search/route.ts @@ -1,59 +1,63 @@ +import { Prisma } from '@prisma/client'; +import _compact from 'lodash-es/compact'; import _isString from 'lodash-es/isString'; -import { string, z } from 'zod'; -import { GlobalRole } from '@/constants'; +import { GlobalPermissions, GlobalRole } from '@/constants'; import createApiHandler from '@/core/api-handler'; import prisma from '@/core/prisma'; import { OkResponse } from '@/core/responses'; -import { prepareUserData } from '@/services/db'; -import { listUsersByEmail } from '@/services/msgraph'; - -const userSearchBodySchema = z.object({ - email: z.string().max(40), -}); +import { searchUsers } from '@/services/db'; +import { listUsersByRoles, findUserByEmail, getKcAdminClient } from '@/services/keycloak/app-realm'; +import { userSearchBodySchema } from '@/validation-schemas'; export const POST = createApiHandler({ - roles: [GlobalRole.User], + permissions: [GlobalPermissions.ViewUsers], validations: { body: userSearchBodySchema }, -})(async ({ session, body }) => { - const { email } = body; - if (email.length < 3) { - return OkResponse({ data: [], totalCount: 0 }); +})(async ({ body }) => { + const kcAdminClient = await getKcAdminClient(); + + let roleEmails: string[] = []; + + if (body.roles.length > 0) { + const roleKcUsers = await listUsersByRoles(body.roles, kcAdminClient); + roleEmails = _compact(roleKcUsers.map((user) => user.email?.toLocaleLowerCase())); + if (roleEmails.length === 0) return OkResponse({ data: [], totalCount: 0 }); } - const users = await listUsersByEmail(email); - - const dbUsers = await Promise.all( - users.map(async (user) => { - const data = await prepareUserData(user); - // The upsert method returns { count: x } when updating data instead of the document. - // Related issue: https://github.com/prisma/prisma/issues/10935 - await prisma.user.upsert({ - where: { email: data.email }, - update: data, - create: data, - }); - - return prisma.user.findUnique({ - where: { email: data.email }, - select: { - id: true, - firstName: true, - lastName: true, - email: true, - upn: true, - idir: true, - officeLocation: true, - jobTitle: true, - image: true, - ministry: true, - archived: true, - createdAt: true, - updatedAt: true, - lastSeen: true, - }, - }); + const result = await searchUsers({ ...body, extraFilter: roleEmails.length ? { email: { in: roleEmails } } : {} }); + + const kcProfiles = await Promise.all(result.data.map((v) => findUserByEmail(v.email, kcAdminClient))); + + result.data = await Promise.all( + result.data.map(async (user, index) => { + const [privateProducts, publicProducts] = await Promise.all([ + prisma.privateCloudProject.findMany({ + where: { + OR: [ + { projectOwnerId: user.id }, + { primaryTechnicalLeadId: user.id }, + { secondaryTechnicalLeadId: user.id }, + { members: { some: { userId: user.id } } }, + ], + }, + select: { licencePlate: true, name: true }, + }), + prisma.publicCloudProject.findMany({ + where: { + OR: [ + { projectOwnerId: user.id }, + { primaryTechnicalLeadId: user.id }, + { secondaryTechnicalLeadId: user.id }, + { expenseAuthorityId: user.id }, + { members: { some: { userId: user.id } } }, + ], + }, + select: { licencePlate: true, name: true }, + }), + ]); + + return { ...user, privateProducts, publicProducts, roles: kcProfiles[index]?.authRoleNames ?? [] }; }), ); - return OkResponse({ data: dbUsers, totalCount: users.length }); + return OkResponse(result); }); diff --git a/app/app/users/all/FilterPanel.tsx b/app/app/users/all/FilterPanel.tsx new file mode 100644 index 000000000..09c02ac28 --- /dev/null +++ b/app/app/users/all/FilterPanel.tsx @@ -0,0 +1,55 @@ +import { Button, LoadingOverlay, Box } from '@mantine/core'; +import { useQuery } from '@tanstack/react-query'; +import { useSnapshot } from 'valtio'; +import FormMultiSelect from '@/components/generic/select/FormMultiSelect'; +import { listKeycloakAuthRoles } from '@/services/backend/keycloak'; +import { pageState } from './state'; + +export default function FilterPanel() { + const pageSnapshot = useSnapshot(pageState); + const { data: authRoles, isFetching: isAuthRolesFetching } = useQuery({ + queryKey: ['roles'], + queryFn: () => listKeycloakAuthRoles(), + }); + + const authRoleNames = (authRoles || []).map((role) => role.name ?? '').sort(); + + return ( + + +
+
+ { + pageState.roles = value; + pageState.page = 1; + }} + classNames={{ wrapper: '' }} + /> +
+ +
+
+
+
+ ); +} diff --git a/app/app/users/all/layout.tsx b/app/app/users/all/layout.tsx new file mode 100644 index 000000000..a1e04c60c --- /dev/null +++ b/app/app/users/all/layout.tsx @@ -0,0 +1,5 @@ +'use client'; + +export default function Layout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/app/app/users/all/page.tsx b/app/app/users/all/page.tsx new file mode 100644 index 000000000..bd8cd5bc4 --- /dev/null +++ b/app/app/users/all/page.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { useSnapshot } from 'valtio'; +import Table from '@/components/generic/table/Table'; +import TableBodyUsers from '@/components/table/TableBodyUsers'; +import { userSorts, GlobalPermissions } from '@/constants'; +import createClientPage from '@/core/client-page'; +import { searchUsers } from '@/services/backend/user'; +import { AdminViewUsers } from '@/types/user'; +import FilterPanel from './FilterPanel'; +import { pageState } from './state'; + +const usersPage = createClientPage({ + permissions: [GlobalPermissions.ViewUsers], + fallbackUrl: '/login?callbackUrl=/home', +}); +export default usersPage(({ session }) => { + const snap = useSnapshot(pageState); + + const { data, isLoading } = useQuery({ + queryKey: ['users', snap], + queryFn: () => searchUsers(snap), + }); + + let users: AdminViewUsers[] = []; + let totalCount = 0; + + if (!isLoading && data) { + users = data.data; + totalCount = data.totalCount; + } + + return ( + <> + { + pageState.page = page; + pageState.pageSize = pageSize; + }} + onSearch={(searchTerm: string) => { + pageState.page = 1; + pageState.search = searchTerm; + }} + onSort={(sortValue) => { + pageState.page = 1; + pageState.sortValue = sortValue; + }} + sortOptions={userSorts.map((v) => v.label)} + filters={} + isLoading={isLoading} + > + +
+ + ); +}); diff --git a/app/app/users/all/state.ts b/app/app/users/all/state.ts new file mode 100644 index 000000000..a7c0d16ca --- /dev/null +++ b/app/app/users/all/state.ts @@ -0,0 +1,16 @@ +import { proxy } from 'valtio'; +import { deepClone } from 'valtio/utils'; +import { userSorts } from '@/constants'; +import { UserSearchBody } from '@/validation-schemas'; + +const initialValue = { + search: '', + page: 1, + pageSize: 10, + roles: [], + sortValue: userSorts[0].label, + sortKey: userSorts[0].sortKey, + sortOrder: userSorts[0].sortOrder, +}; + +export const pageState = proxy(deepClone(initialValue)); diff --git a/app/components/generic/select/FormSingleSelect.tsx b/app/components/generic/select/FormSingleSelect.tsx index 46b31c649..a7d1949a3 100644 --- a/app/components/generic/select/FormSingleSelect.tsx +++ b/app/components/generic/select/FormSingleSelect.tsx @@ -1,6 +1,7 @@ 'use client'; import { Select, ComboboxData, ComboboxItem } from '@mantine/core'; +import _isNil from 'lodash-es/isNil'; import _kebabCase from 'lodash-es/kebabCase'; import { FocusEventHandler } from 'react'; import { cn } from '@/utils'; @@ -47,7 +48,7 @@ export default function FormSingleSelect({ placeholder="select..." data={data} onChange={(val, option) => { - if (val) onChange(val, option); + if (!_isNil(val)) onChange(val, option); }} onBlur={onBlur} value={value} diff --git a/app/components/layouts/UserMenu.tsx b/app/components/layouts/UserMenu.tsx index 7df474fc3..e33b23d43 100644 --- a/app/components/layouts/UserMenu.tsx +++ b/app/components/layouts/UserMenu.tsx @@ -6,6 +6,7 @@ import { IconApi, IconVirusSearch, IconScan, + IconUsersGroup, IconPresentationAnalytics, IconLogout, IconProps, @@ -77,6 +78,12 @@ export default function UserMenu() { Icon: IconApi, href: '/team-api-accounts', }, + { + text: 'Users', + Icon: IconUsersGroup, + href: '/users/all', + permission: 'viewUsers', + }, { text: 'General Analytics', Icon: IconPresentationAnalytics, diff --git a/app/components/table/TableBodyUsers.tsx b/app/components/table/TableBodyUsers.tsx new file mode 100644 index 000000000..0561f3539 --- /dev/null +++ b/app/components/table/TableBodyUsers.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { Avatar, Badge, Table, Group, Text, UnstyledButton, Pill } from '@mantine/core'; +import _get from 'lodash-es/get'; +import _truncate from 'lodash-es/truncate'; +import React from 'react'; +import { formatFullName } from '@/helpers/user'; +import { getUserImageData } from '@/helpers/user-image'; +import { AdminViewUsers } from '@/types/user'; +import { formatDate } from '@/utils/date'; + +interface TableProps { + data: AdminViewUsers[]; + isLoading: boolean; +} + +export default function TableBodyUsers({ data, isLoading = false }: TableProps) { + if (isLoading) { + return null; + } + + const rows = data.length ? ( + data.map((item, index) => ( + + + {}}> + +
+ + {item.id ? ( +
+ {formatFullName(item)} + {item.ministry && ( + + {item.ministry} + + )} +
+ ) : ( + Click to select member + )} +
+ + {item.email} + +
+
+
+ + + {item.jobTitle && ( +
+ + {item.jobTitle} + +
+ )} + {item.officeLocation && ( +
+ + {item.officeLocation} + +
+ )} +
+ + + {item.id && ( + + {item.roles.map((role) => ( + {role} + ))} + + )} + + + + {item.privateProducts.length} + + / + + {item.publicProducts.length} + + + {formatDate(item.lastSeen) || has not yet logged in} + +
+ )) + ) : ( + + + No users found + + + ); + + return ( + + + + + User + Position + Roles + # of Products + Last active + + + {rows} +
+
+ ); +} diff --git a/app/components/users/UserAutocomplete.tsx b/app/components/users/UserAutocomplete.tsx index 794b7d0a9..f3420138c 100644 --- a/app/components/users/UserAutocomplete.tsx +++ b/app/components/users/UserAutocomplete.tsx @@ -5,7 +5,7 @@ import _throttle from 'lodash-es/throttle'; import { useRef, useState } from 'react'; import { formatFullName } from '@/helpers/user'; import { getUserImageData } from '@/helpers/user-image'; -import { searchUsers } from '@/services/backend/users'; +import { searchMSUsers } from '@/services/backend/msgraph'; function UserOption({ data }: { data: User }) { return ( @@ -60,7 +60,7 @@ export default function UserAutocomplete({ onSelect }: { onSelect: (user?: User) _throttle( async (query: string) => { setLoading(true); - const result = await searchUsers(query); + const result = await searchMSUsers(query); setData(result.data); setLoading(false); return result.data; diff --git a/app/constants/user.ts b/app/constants/user.ts index 5aae1b0a9..6c17e5610 100644 --- a/app/constants/user.ts +++ b/app/constants/user.ts @@ -1,3 +1,5 @@ +import { Prisma } from '@prisma/client'; + export enum GlobalPermissions { CreatePrivateCloudProducts = 'createPrivateCloudProducts', ViewAllPrivateCloudProducts = 'viewAllPrivateCloudProducts', @@ -30,6 +32,8 @@ export enum GlobalPermissions { ViewPublicAnalytics = 'viewPublicAnalytics', DownloadBillingMou = 'downloadBillingMou', + + ViewUsers = 'viewUsers', } export enum GlobalRole { @@ -77,3 +81,36 @@ export const RoleToSessionProp = { }; export const sessionRolePropKeys = Object.values(RoleToSessionProp); + +export const userSorts = [ + { + label: 'Last active date (new to old)', + sortKey: 'lastSeen', + sortOrder: Prisma.SortOrder.desc, + }, + { + label: 'Last active date (old to new)', + sortKey: 'lastSeen', + sortOrder: Prisma.SortOrder.asc, + }, + { + label: 'First Name (A-Z)', + sortKey: 'firstName', + sortOrder: Prisma.SortOrder.asc, + }, + { + label: 'First Name (Z-A)', + sortKey: 'firstName', + sortOrder: Prisma.SortOrder.desc, + }, + { + label: 'Last Name (A-Z)', + sortKey: 'lastName', + sortOrder: Prisma.SortOrder.asc, + }, + { + label: 'Last Name (Z-A)', + sortKey: 'lastName', + sortOrder: Prisma.SortOrder.desc, + }, +]; diff --git a/app/core/auth-options.ts b/app/core/auth-options.ts index 387e1facb..03dd5eaac 100644 --- a/app/core/auth-options.ts +++ b/app/core/auth-options.ts @@ -157,6 +157,7 @@ export async function generateSession({ session, token }: { session: Session; to viewPrivateAnalytics: session.isAdmin || session.isAnalyzer || session.isPrivateAnalyzer, downloadBillingMou: session.isBillingReviewer || session.isBillingReader, + viewUsers: session.isAdmin, }; return session; diff --git a/app/services/backend/users.ts b/app/services/backend/msgraph.ts similarity index 76% rename from app/services/backend/users.ts rename to app/services/backend/msgraph.ts index fa3d15c20..3ce548fd7 100644 --- a/app/services/backend/users.ts +++ b/app/services/backend/msgraph.ts @@ -4,10 +4,10 @@ import { instance as baseInstance } from './axios'; export const instance = axios.create({ ...baseInstance.defaults, - baseURL: `${baseInstance.defaults.baseURL}/users`, + baseURL: `${baseInstance.defaults.baseURL}/msgraph`, }); -export async function searchUsers(email: string) { +export async function searchMSUsers(email: string) { const result = await instance .post<{ data: User[]; totalCount: number }>('/search', { email }) .then((res) => res.data); diff --git a/app/services/backend/user.ts b/app/services/backend/user.ts new file mode 100644 index 000000000..adc153195 --- /dev/null +++ b/app/services/backend/user.ts @@ -0,0 +1,34 @@ +import { Prisma, User } from '@prisma/client'; +import axios from 'axios'; +import { userSorts } from '@/constants'; +import { AdminViewUsers } from '@/types/user'; +import { UserSearchBody } from '@/validation-schemas'; +import { instance as baseInstance } from './axios'; + +export const instance = axios.create({ + ...baseInstance.defaults, + baseURL: `${baseInstance.defaults.baseURL}/users`, +}); + +function prepareSearchPayload(data: UserSearchBody) { + const reqData = { ...data }; + const selectedOption = userSorts.find((sort) => sort.label === reqData.sortValue); + + if (selectedOption) { + reqData.sortKey = selectedOption.sortKey; + reqData.sortOrder = selectedOption.sortOrder; + } else { + reqData.sortKey = ''; + reqData.sortOrder = Prisma.SortOrder.desc; + } + + return reqData; +} + +export async function searchUsers(data: UserSearchBody) { + const reqData = prepareSearchPayload(data); + const result = await instance + .post<{ data: AdminViewUsers[]; totalCount: number }>('/search', reqData) + .then((res) => res.data); + return result; +} diff --git a/app/services/db/user.ts b/app/services/db/user.ts index 9b2fd3dae..c40be4559 100644 --- a/app/services/db/user.ts +++ b/app/services/db/user.ts @@ -2,13 +2,16 @@ import { Prisma, User } from '@prisma/client'; import _castArray from 'lodash-es/castArray'; import _compact from 'lodash-es/compact'; import _forEach from 'lodash-es/forEach'; +import _isNumber from 'lodash-es/isNumber'; import _uniq from 'lodash-es/uniq'; import { logger } from '@/core/logging'; import prisma from '@/core/prisma'; import { proxyUsers } from '@/helpers/mock-users'; +import { parsePaginationParams } from '@/helpers/pagination'; import { getUserByEmail, getUserPhoto, processMsUser } from '@/services/msgraph'; import { MsUser, AppUser } from '@/types/user'; import { arrayBufferToBase64 } from '@/utils/base64-arraybuffer'; +import { UserSearchBody } from '@/validation-schemas'; export async function prepareUserData(user: AppUser, extra = {}) { const email = user.email.toLowerCase(); @@ -96,3 +99,72 @@ export async function createProxyUsers() { return dbUsers; } + +const defaultSortKey = 'lastSeen'; + +export async function searchUsers({ + skip, + take, + page, + pageSize, + search = '', + sortKey = defaultSortKey, + sortOrder = Prisma.SortOrder.desc, + extraFilter, +}: UserSearchBody & { + skip?: number; + take?: number; + extraFilter?: Prisma.UserWhereInput; +}) { + if (!_isNumber(skip) && !_isNumber(take) && page && pageSize) { + ({ skip, take } = parsePaginationParams(page, pageSize, 10)); + } + + const where: Prisma.UserWhereInput = extraFilter ?? {}; + const orderBy = { [sortKey || defaultSortKey]: Prisma.SortOrder[sortOrder] }; + + if (search === '*') search = ''; + + if (search) { + const searchCriteria: Prisma.StringFilter<'User'> = { contains: search, mode: 'insensitive' }; + + where.OR = [ + { firstName: searchCriteria }, + { lastName: searchCriteria }, + { email: searchCriteria }, + { officeLocation: searchCriteria }, + { jobTitle: searchCriteria }, + { ministry: searchCriteria }, + ]; + } + + const [data, totalCount] = await Promise.all([ + prisma.user.findMany({ + where, + skip, + take, + orderBy, + select: { + id: true, + firstName: true, + lastName: true, + email: true, + upn: true, + idir: true, + officeLocation: true, + jobTitle: true, + image: true, + ministry: true, + archived: true, + createdAt: true, + updatedAt: true, + lastSeen: true, + }, + }), + prisma.user.count({ + where, + }), + ]); + + return { data, totalCount }; +} diff --git a/app/services/keycloak/app-realm.ts b/app/services/keycloak/app-realm.ts index e2f9f22eb..c2ad98449 100644 --- a/app/services/keycloak/app-realm.ts +++ b/app/services/keycloak/app-realm.ts @@ -102,3 +102,78 @@ export async function findUserEmailsByAuthRole(roleName: GlobalRole, kcAdminClie const users = await findUsersByClientRole(AUTH_RESOURCE, roleName, kcAdminClient); return users.map((v) => v.email ?? '').filter(Boolean); } + +export async function listUsers(kcAdminClient?: KcAdminClient) { + if (!kcAdminClient) kcAdminClient = await getKcAdminClient(); + + // See https://www.keycloak.org/docs-api/latest/rest-api/index.html#_get_adminrealmsrealmusers + const users = await kcAdminClient.users.find({ + realm: AUTH_RELM, + briefRepresentation: false, + first: 0, + max: 100, + }); + + return users; +} + +export async function listUsersByRole(roleName: string, kcAdminClient?: KcAdminClient) { + if (!kcAdminClient) kcAdminClient = await getKcAdminClient(); + + const client = await findClient(AUTH_RESOURCE, kcAdminClient); + if (!client?.id) return []; + + const users = await kcAdminClient.clients.findUsersWithRole({ + realm: AUTH_RELM, + id: client.id, + roleName, + briefRepresentation: false, + }); + + return users; +} + +export async function listUsersByRoles(roleNames: string[], kcAdminClient?: KcAdminClient) { + if (!kcAdminClient) kcAdminClient = await getKcAdminClient(); + + const client = await findClient(AUTH_RESOURCE, kcAdminClient); + if (!client?.id) return []; + + const userGroups = await Promise.all( + roleNames.map((roleName) => + kcAdminClient.clients.findUsersWithRole({ + realm: AUTH_RELM, + id: client.id as string, + roleName, + briefRepresentation: false, + }), + ), + ); + + const users = userGroups.flat(); + return users; +} + +export async function findUserByEmail(email: string, kcAdminClient?: KcAdminClient) { + if (!kcAdminClient) kcAdminClient = await getKcAdminClient(); + + const users = await kcAdminClient.users.find({ + realm: AUTH_RELM, + email, + exact: true, + userProfileMetadata: false, + }); + + if (users.length === 0) return null; + + const authClient = await findClient(AUTH_RESOURCE, kcAdminClient); + if (!authClient?.id) return null; + + const authRoles = await kcAdminClient.users.listClientRoleMappings({ + realm: AUTH_RELM, + id: users[0].id as string, + clientUniqueId: authClient.id, + }); + + return { ...users[0], authRoleNames: authRoles.map((role) => role.name ?? '') }; +} diff --git a/app/types/next-auth.d.ts b/app/types/next-auth.d.ts index 66ce50903..a7f949e46 100644 --- a/app/types/next-auth.d.ts +++ b/app/types/next-auth.d.ts @@ -43,6 +43,8 @@ declare module 'next-auth' { viewPublicAnalytics: boolean; downloadBillingMou: boolean; + + viewUsers: boolean; } interface Session extends DefaultSession { diff --git a/app/types/user.ts b/app/types/user.ts index ba33173f4..ad8d28a1e 100644 --- a/app/types/user.ts +++ b/app/types/user.ts @@ -1,3 +1,5 @@ +import { User } from '@prisma/client'; + export interface MsUser { id: string; userPrincipalName: string; @@ -28,3 +30,9 @@ export interface AppUser { export interface AppUserWithRoles extends AppUser { roles: string[]; } + +export type AdminViewUsers = User & { + roles: string[]; + privateProducts: { name: string; licencePlate: string }[]; + publicProducts: { name: string; licencePlate: string }[]; +}; diff --git a/app/validation-schemas/index.ts b/app/validation-schemas/index.ts new file mode 100644 index 000000000..3e3e6c15c --- /dev/null +++ b/app/validation-schemas/index.ts @@ -0,0 +1,6 @@ +export * from './api-accounts'; +export * from './private-cloud'; +export * from './public-cloud'; +export * from './security-config'; +export * from './shared'; +export * from './user'; diff --git a/app/validation-schemas/user.ts b/app/validation-schemas/user.ts new file mode 100644 index 000000000..1226580fe --- /dev/null +++ b/app/validation-schemas/user.ts @@ -0,0 +1,16 @@ +import { Prisma } from '@prisma/client'; +import _isString from 'lodash-es/isString'; +import { z } from 'zod'; +import { processEnumString, processUpperEnumString, processBoolean } from '@/utils/zod'; + +export const userSearchBodySchema = z.object({ + search: z.string().optional(), + page: z.number().optional(), + pageSize: z.number().optional(), + roles: z.array(z.string()), + sortValue: z.string().optional(), + sortKey: z.string().optional(), + sortOrder: z.preprocess(processEnumString, z.nativeEnum(Prisma.SortOrder).optional()), +}); + +export type UserSearchBody = z.infer; From bbc9179efed032079a7ff9ab7fb58f49c99a9623 Mon Sep 17 00:00:00 2001 From: "j.dev" Date: Fri, 15 Nov 2024 11:05:33 -0800 Subject: [PATCH 2/2] chore(3972): add product hover popup --- .github/workflows/release-tag-changelog.yml | 2 +- .../analytics/csv/common-components/route.ts | 50 +++++++++++++++ .../users/all/PrivateCloudProductsCard.tsx | 63 +++++++++++++++++++ .../users/all/TableBody.tsx} | 21 ++++--- app/app/users/all/page.tsx | 4 +- .../generic/accordion/PageAccordion.tsx | 4 +- .../generic/button/ExternalLink.tsx | 2 +- app/core/responses.ts | 2 +- 8 files changed, 134 insertions(+), 14 deletions(-) create mode 100644 app/app/api/analytics/csv/common-components/route.ts create mode 100644 app/app/users/all/PrivateCloudProductsCard.tsx rename app/{components/table/TableBodyUsers.tsx => app/users/all/TableBody.tsx} (82%) diff --git a/.github/workflows/release-tag-changelog.yml b/.github/workflows/release-tag-changelog.yml index 8154c07ed..edc733d47 100644 --- a/.github/workflows/release-tag-changelog.yml +++ b/.github/workflows/release-tag-changelog.yml @@ -57,7 +57,7 @@ jobs: - name: Set output variables id: vars run: | - pr_title="chore: release candidate v${{ steps.input.outputs.tag }}" + pr_title="chore(release): release candidate v${{ steps.input.outputs.tag }}" pr_body=$(git tag -l --format='%(contents)' v${{ steps.input.outputs.tag }}) echo "pr_title=$pr_title" >> $GITHUB_OUTPUT echo "base=$GITHUB_REF_NAME" >> $GITHUB_OUTPUT diff --git a/app/app/api/analytics/csv/common-components/route.ts b/app/app/api/analytics/csv/common-components/route.ts new file mode 100644 index 000000000..c19b66e70 --- /dev/null +++ b/app/app/api/analytics/csv/common-components/route.ts @@ -0,0 +1,50 @@ +import { CommonComponentsOptions, ProjectStatus } from '@prisma/client'; +import _sum from 'lodash-es/sum'; +import { GlobalPermissions } from '@/constants'; +import createApiHandler from '@/core/api-handler'; +import prisma from '@/core/prisma'; +import { CsvResponse } from '@/core/responses'; + +const apiHandler = createApiHandler({ + permissions: [GlobalPermissions.ViewGeneralAnalytics], +}); + +function commonComponentToText(item: CommonComponentsOptions) { + if (item.implemented) return 'implemented'; + if (item.planningToUse) return 'planning to use'; + return ''; +} +export const GET = apiHandler(async () => { + const products = await prisma.privateCloudProject.findMany({ + where: { status: ProjectStatus.ACTIVE }, + select: { + name: true, + licencePlate: true, + commonComponents: true, + }, + }); + + return CsvResponse( + products.map((row) => { + return { + 'Product name': row.name, + 'Licence Plate': row.licencePlate, + 'Address and Geolocation': commonComponentToText(row.commonComponents.addressAndGeolocation), + 'Workflow Management': commonComponentToText(row.commonComponents.workflowManagement), + 'Form Design and Submission': commonComponentToText(row.commonComponents.formDesignAndSubmission), + 'Identity management': commonComponentToText(row.commonComponents.identityManagement), + 'Payment services': commonComponentToText(row.commonComponents.paymentServices), + 'Document Management': commonComponentToText(row.commonComponents.documentManagement), + 'End user notification and subscription service': commonComponentToText( + row.commonComponents.endUserNotificationAndSubscription, + ), + Publishing: commonComponentToText(row.commonComponents.publishing), + 'Business Intelligence Dashboard and Metrics reporting': commonComponentToText( + row.commonComponents.businessIntelligence, + ), + Other: row.commonComponents.other, + }; + }), + 'common-components.csv', + ); +}); diff --git a/app/app/users/all/PrivateCloudProductsCard.tsx b/app/app/users/all/PrivateCloudProductsCard.tsx new file mode 100644 index 000000000..8045371da --- /dev/null +++ b/app/app/users/all/PrivateCloudProductsCard.tsx @@ -0,0 +1,63 @@ +import { HoverCard, Group } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import ExternalLink from '@/components/generic/button/ExternalLink'; +import { cn } from '@/utils'; + +export default function PrivateCloudProductsCard({ + products = [], + className = '', + context, + children, +}: { + products: { name: string; licencePlate: string }[]; + className?: string; + context: 'private-cloud' | 'public-cloud'; + children: React.ReactNode; +}) { + const [opened, { close, open }] = useDisclosure(false); + + const title = context === 'private-cloud' ? 'Private Cloud Products' : 'Public Cloud Products'; + + const emptyMessage = + context === 'private-cloud' ? 'No private cloud products associated.' : 'No public cloud products associated.'; + + const baseUrl = `/${context}/products/`; + + return ( + + +
+ {children} +
+
+ +
+ {products.length === 0 ? ( +
{emptyMessage}
+ ) : ( + <> +
{title}
+ {products.map((product) => { + return ( +
+
+
+ {product.name} +
+
+
+ +
+
+ ); + })} + + )} +
+
+
+ ); +} diff --git a/app/components/table/TableBodyUsers.tsx b/app/app/users/all/TableBody.tsx similarity index 82% rename from app/components/table/TableBodyUsers.tsx rename to app/app/users/all/TableBody.tsx index 0561f3539..330cd4682 100644 --- a/app/components/table/TableBodyUsers.tsx +++ b/app/app/users/all/TableBody.tsx @@ -8,13 +8,14 @@ import { formatFullName } from '@/helpers/user'; import { getUserImageData } from '@/helpers/user-image'; import { AdminViewUsers } from '@/types/user'; import { formatDate } from '@/utils/date'; +import PrivateCloudProductsCard from './PrivateCloudProductsCard'; interface TableProps { data: AdminViewUsers[]; isLoading: boolean; } -export default function TableBodyUsers({ data, isLoading = false }: TableProps) { +export default function TableBody({ data, isLoading = false }: TableProps) { if (isLoading) { return null; } @@ -74,13 +75,19 @@ export default function TableBodyUsers({ data, isLoading = false }: TableProps) )} - - {item.privateProducts.length} - + + + {item.privateProducts.length} + + + / - - {item.publicProducts.length} - + + + + {item.publicProducts.length} + + {formatDate(item.lastSeen) || has not yet logged in} diff --git a/app/app/users/all/page.tsx b/app/app/users/all/page.tsx index bd8cd5bc4..71e336e04 100644 --- a/app/app/users/all/page.tsx +++ b/app/app/users/all/page.tsx @@ -3,13 +3,13 @@ import { useQuery } from '@tanstack/react-query'; import { useSnapshot } from 'valtio'; import Table from '@/components/generic/table/Table'; -import TableBodyUsers from '@/components/table/TableBodyUsers'; import { userSorts, GlobalPermissions } from '@/constants'; import createClientPage from '@/core/client-page'; import { searchUsers } from '@/services/backend/user'; import { AdminViewUsers } from '@/types/user'; import FilterPanel from './FilterPanel'; import { pageState } from './state'; +import TableBody from './TableBody'; const usersPage = createClientPage({ permissions: [GlobalPermissions.ViewUsers], @@ -56,7 +56,7 @@ export default usersPage(({ session }) => { filters={} isLoading={isLoading} > - + ); diff --git a/app/components/generic/accordion/PageAccordion.tsx b/app/components/generic/accordion/PageAccordion.tsx index 87e8aac05..ac15f8939 100644 --- a/app/components/generic/accordion/PageAccordion.tsx +++ b/app/components/generic/accordion/PageAccordion.tsx @@ -39,10 +39,10 @@ function InnerPageAccordion({ <> {showToggles && (
- -
diff --git a/app/components/generic/button/ExternalLink.tsx b/app/components/generic/button/ExternalLink.tsx index 4fed3f448..73753cae2 100644 --- a/app/components/generic/button/ExternalLink.tsx +++ b/app/components/generic/button/ExternalLink.tsx @@ -10,7 +10,7 @@ export default function ExternalLink({ }: { href: string; className?: string; - children: React.ReactNode; + children?: React.ReactNode; }) { return ( >(data: T[], filename = status: 200, headers: { 'Content-Type': 'text/csv', - 'Content-Disposition': `'attachment; filename=${filename}'`, + 'Content-Disposition': `attachment; filename=${filename}`, }, });