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/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/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 (
+
+ );
+ })}
+ >
+ )}
+
+
+
+ );
+}
diff --git a/app/app/users/all/TableBody.tsx b/app/app/users/all/TableBody.tsx
new file mode 100644
index 000000000..330cd4682
--- /dev/null
+++ b/app/app/users/all/TableBody.tsx
@@ -0,0 +1,120 @@
+'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';
+import PrivateCloudProductsCard from './PrivateCloudProductsCard';
+
+interface TableProps {
+ data: AdminViewUsers[];
+ isLoading: boolean;
+}
+
+export default function TableBody({ 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/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..71e336e04
--- /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 { 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],
+ 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/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 (
{
- 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/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/core/responses.ts b/app/core/responses.ts
index 128084ef8..17a799c56 100644
--- a/app/core/responses.ts
+++ b/app/core/responses.ts
@@ -12,7 +12,7 @@ export function CsvResponse>(data: T[], filename =
status: 200,
headers: {
'Content-Type': 'text/csv',
- 'Content-Disposition': `'attachment; filename=${filename}'`,
+ 'Content-Disposition': `attachment; filename=${filename}`,
},
});
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;