Skip to content

Commit

Permalink
Merge pull request #4260 from bcgov/feat/3972
Browse files Browse the repository at this point in the history
  • Loading branch information
junminahn authored Nov 16, 2024
2 parents c78704f + bbc9179 commit 89c68a4
Show file tree
Hide file tree
Showing 28 changed files with 771 additions and 56 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/release-tag-changelog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions app/app/api/analytics/csv/common-components/route.ts
Original file line number Diff line number Diff line change
@@ -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',
);
});
10 changes: 10 additions & 0 deletions app/app/api/keycloak/users/_operations/list.ts
Original file line number Diff line number Diff line change
@@ -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);
}
11 changes: 11 additions & 0 deletions app/app/api/keycloak/users/route.ts
Original file line number Diff line number Diff line change
@@ -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;
});
59 changes: 59 additions & 0 deletions app/app/api/msgraph/search/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
96 changes: 50 additions & 46 deletions app/app/api/users/search/route.ts
Original file line number Diff line number Diff line change
@@ -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);
});
55 changes: 55 additions & 0 deletions app/app/users/all/FilterPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box pos="relative">
<LoadingOverlay
visible={!authRoles || isAuthRolesFetching}
zIndex={1000}
overlayProps={{ radius: 'sm', blur: 2 }}
loaderProps={{ color: 'pink', type: 'bars' }}
/>
<div className="grid grid-cols-1 gap-y-2 md:grid-cols-12 md:gap-x-3">
<div className="col-span-12">
<FormMultiSelect
name="roles"
label="Global Roles"
value={pageSnapshot.roles ?? []}
data={authRoleNames}
onChange={(value) => {
pageState.roles = value;
pageState.page = 1;
}}
classNames={{ wrapper: '' }}
/>
<div className="text-right">
<Button
color="primary"
size="compact-md"
className="mt-1"
onClick={() => {
pageState.roles = authRoleNames;
pageState.page = 1;
}}
>
Select All
</Button>
</div>
</div>
</div>
</Box>
);
}
63 changes: 63 additions & 0 deletions app/app/users/all/PrivateCloudProductsCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<HoverCard shadow="md" position="top">
<HoverCard.Target>
<div className={cn('cursor-help inline-block', className)} onMouseEnter={open} onMouseLeave={close}>
{children}
</div>
</HoverCard.Target>
<HoverCard.Dropdown>
<div className="overflow-y-auto max-h-80">
{products.length === 0 ? (
<div className="italic text-sm">{emptyMessage}</div>
) : (
<>
<div className="mb-2 underline">{title}</div>
{products.map((product) => {
return (
<div
key={product.licencePlate}
className="hover:bg-gray-100 transition-colors duration-200 grid grid-cols-5 gap-4 px-2 py-1 text-sm"
>
<div className="col-span-4">
<div>
<span className="font-semibold">{product.name}</span>
</div>
</div>
<div className="col-span-1 text-right">
<ExternalLink href={`${baseUrl}/${product.licencePlate}/edit`} />
</div>
</div>
);
})}
</>
)}
</div>
</HoverCard.Dropdown>
</HoverCard>
);
}
Loading

0 comments on commit 89c68a4

Please sign in to comment.