From ad644db512dfdcfadd98d8cf812cc2038a42adb2 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Sun, 8 Sep 2024 19:19:55 +0400 Subject: [PATCH 01/17] feat: audit logs on organization-level --- .../AuditLogsPage/components/LogsFilter.tsx | 68 ++++++++++--------- .../AuditLogsPage/components/LogsSection.tsx | 25 ++++++- .../AuditLogsPage/components/LogsTable.tsx | 21 ++++-- .../AuditLogsPage/components/LogsTableRow.tsx | 4 +- 4 files changed, 78 insertions(+), 40 deletions(-) diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx index 011e1a915..a9f10875c 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { Control, Controller, UseFormReset } from "react-hook-form"; import { faFilterCircleXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; import { Button, DatePicker, FormControl, Select, SelectItem } from "@app/components/v2"; import { useWorkspace } from "@app/context"; @@ -19,11 +20,13 @@ const userAgentTypes = Object.entries(userAgentTTypeoNameMap).map(([value, label })); type Props = { + presetActor?: string; + className?: string; control: Control; reset: UseFormReset; }; -export const LogsFilter = ({ control, reset }: Props) => { +export const LogsFilter = ({ presetActor, className, control, reset }: Props) => { const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false); const [isEndDatePickerOpen, setIsEndDatePickerOpen] = useState(false); @@ -69,8 +72,13 @@ export const LogsFilter = ({ control, reset }: Props) => { }; return ( -
-
+
+
{ label="Event" errorText={error?.message} isError={Boolean(error)} - className="mr-4 w-40" + className="w-40" > { label="Source" errorText={error?.message} isError={Boolean(error)} - className="mr-4 w-40" + className="w-40" > setFilteredProjectName(e.target.value)} + leftIcon={} + placeholder="Search projects..." + /> + + + + + + + + + {filteredMemberships?.map(({ project, roles }) => { + return ( + + + + + ); + })} + +
Project
+ +

+ {project.name} +

+ + {/* If there's only one role, display the role.name. If more, display role.name (+X more) */} + role.role.charAt(0).toUpperCase() + role.role.slice(1)) + .join(", ")} + > +

+ {roles.length === 1 + ? roles[0].role + : `${roles[0].role} (+${roles.length - 1} more)`} +

+
+
+ + {(isAllowed) => { + return ( +
+ +
+ ); + }} +
+
+ {!filteredMemberships.length && } +
+ + + ); +}; + +export const UserGroupsRow = ({ group, handlePopUpOpen }: Props) => { + const [isProjectsModalOpen, setIsProjectsModalOpen] = useState(false); + + return ( + <> + setIsProjectsModalOpen(true)} + > + {group.name} + + {group.projectMemberships.length} Project + {group.projectMemberships.length > 1 || group.projectMemberships.length === 0 ? "s" : ""} + + + {true && ( +
+ + { + e.stopPropagation(); + handlePopUpOpen("removeUserFromGroup", { + groupSlug: group.slug + }); + }} + > + + + +
+ )} + + + + + ); +}; diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsSection.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsSection.tsx new file mode 100644 index 000000000..41340aec4 --- /dev/null +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsSection.tsx @@ -0,0 +1,68 @@ +import { useCallback } from "react"; + +import { createNotification } from "@app/components/notifications"; +import { DeleteActionModal } from "@app/components/v2"; +import { useRemoveUserFromGroup } from "@app/hooks/api"; +import { OrgUser } from "@app/hooks/api/users/types"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { UserGroupsTable } from "./UserGroupsTable"; + +type Props = { + orgMembership: OrgUser; +}; + +export const UserGroupsSection = ({ orgMembership }: Props) => { + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "removeUserFromGroup" + ] as const); + + const { mutateAsync: removeUserFromGroup } = useRemoveUserFromGroup(); + + const handleRemoveUserFromGroup = useCallback(async (groupSlug: string) => { + try { + await removeUserFromGroup({ + slug: groupSlug, + username: orgMembership.user.username + }); + + createNotification({ + type: "success", + text: "User removed from group successfully" + }); + + handlePopUpClose("removeUserFromGroup"); + } catch (error) { + createNotification({ + type: "error", + text: "Failed to remove user from group" + }); + } + }, []); + + return ( + <> +
+
+

Groups

+
+ + +
+ + handlePopUpToggle("removeUserFromGroup", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => { + const popupData = popUp?.removeUserFromGroup?.data as { + groupSlug: string; + }; + + return handleRemoveUserFromGroup(popupData.groupSlug); + }} + /> + + ); +}; diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx new file mode 100644 index 000000000..914df9985 --- /dev/null +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx @@ -0,0 +1,43 @@ +import { faFolder } from "@fortawesome/free-solid-svg-icons"; + +import { EmptyState, Table, TableContainer, TBody, Th, THead, Tr } from "@app/components/v2"; +import { OrgUser } from "@app/hooks/api/types"; +import { useListUserGroupMemberships } from "@app/hooks/api/users/queries"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { UserGroupsRow } from "./UserGroupsRow"; + +type Props = { + orgMembership: OrgUser; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromGroup"]>, data?: {}) => void; +}; + +export const UserGroupsTable = ({ handlePopUpOpen, orgMembership }: Props) => { + const { data: groups, isLoading } = useListUserGroupMemberships(orgMembership.user.username); + + return ( + + + + + + + + + + {groups?.map((group) => ( + + ))} + +
NameProjects +
+ {!isLoading && !groups?.length && ( + + )} +
+ ); +}; From 4b66a9343cd3012e25c08128bb419871f6b4dd08 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Sun, 8 Sep 2024 19:20:32 +0400 Subject: [PATCH 03/17] feat: audit logs section --- .../UserAuditLogsSection.tsx | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAuditLogsSection.tsx diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAuditLogsSection.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAuditLogsSection.tsx new file mode 100644 index 000000000..16540a8cf --- /dev/null +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAuditLogsSection.tsx @@ -0,0 +1,49 @@ +import { useState } from "react"; +import { faFilter } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { IconButton, Tooltip } from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { withPermission } from "@app/hoc"; +import { OrgUser } from "@app/hooks/api/types"; +import { LogsSection } from "@app/views/Project/AuditLogsPage/components"; + +type Props = { + orgMembership: OrgUser; +}; + +export const UserAuditLogsSection = withPermission( + ({ orgMembership }: Props) => { + const [showFilter, setShowFilter] = useState(false); + + return ( +
+
+

Audit Logs

+ + + setShowFilter(!showFilter)} + > +
+

Filter

+ +
+
+
+
+ +
+ ); + }, + { action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Member } +); From bda9bb3d6140042914a2f6d59c20b8cc387be4b7 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Sun, 8 Sep 2024 19:21:17 +0400 Subject: [PATCH 04/17] fix: rename list audit logs and include project --- backend/src/ee/routes/v1/project-router.ts | 6 ++- .../ee/services/audit-log/audit-log-dal.ts | 37 +++++++++++++++---- .../services/audit-log/audit-log-service.ts | 28 ++++++++------ .../ee/services/audit-log/audit-log-types.ts | 4 +- 4 files changed, 53 insertions(+), 22 deletions(-) diff --git a/backend/src/ee/routes/v1/project-router.ts b/backend/src/ee/routes/v1/project-router.ts index aa32fcb5f..b3cff0322 100644 --- a/backend/src/ee/routes/v1/project-router.ts +++ b/backend/src/ee/routes/v1/project-router.ts @@ -122,6 +122,10 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }) .merge( z.object({ + project: z.object({ + name: z.string(), + slug: z.string() + }), event: z.object({ type: z.string(), metadata: z.any() @@ -138,7 +142,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const auditLogs = await server.services.auditLog.listProjectAuditLogs({ + const auditLogs = await server.services.auditLog.listAuditLogs({ actorId: req.permission.id, actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, diff --git a/backend/src/ee/services/audit-log/audit-log-dal.ts b/backend/src/ee/services/audit-log/audit-log-dal.ts index bcb76e6a5..3021beb0d 100644 --- a/backend/src/ee/services/audit-log/audit-log-dal.ts +++ b/backend/src/ee/services/audit-log/audit-log-dal.ts @@ -1,9 +1,9 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; +import { AuditLogsSchema, TableName } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify, stripUndefinedInWhere } from "@app/lib/knex"; +import { ormify, selectAllTableCols, stripUndefinedInWhere } from "@app/lib/knex"; import { logger } from "@app/lib/logger"; import { QueueName } from "@app/queue"; @@ -33,23 +33,44 @@ export const auditLogDALFactory = (db: TDbClient) => { .where( stripUndefinedInWhere({ projectId, - orgId, + [`${TableName.AuditLog}.orgId`]: orgId, eventType, - actor, userAgentType }) ) + + .leftJoin(TableName.Project, `${TableName.AuditLog}.projectId`, `${TableName.Project}.id`) + + .select(selectAllTableCols(TableName.AuditLog)) + + .select( + db.ref("name").withSchema(TableName.Project).as("projectName"), + db.ref("slug").withSchema(TableName.Project).as("projectSlug") + ) + .limit(limit) .offset(offset) - .orderBy("createdAt", "desc"); + .orderBy(`${TableName.AuditLog}.createdAt`, "desc"); + + if (actor) { + void sqlQuery.whereRaw(`"actorMetadata"->>'userId' = ?`, [actor]); + } + if (startDate) { - void sqlQuery.where("createdAt", ">=", startDate); + void sqlQuery.where(`${TableName.AuditLog}.createdAt`, ">=", startDate); } if (endDate) { - void sqlQuery.where("createdAt", "<=", endDate); + void sqlQuery.where(`${TableName.AuditLog}.createdAt`, "<=", endDate); } const docs = await sqlQuery; - return docs; + + return docs.map((doc) => ({ + ...AuditLogsSchema.parse(doc), + project: { + name: doc.projectName, + slug: doc.projectSlug + } + })); } catch (error) { throw new DatabaseError({ error }); } diff --git a/backend/src/ee/services/audit-log/audit-log-service.ts b/backend/src/ee/services/audit-log/audit-log-service.ts index 2916eb412..2fc7e0de3 100644 --- a/backend/src/ee/services/audit-log/audit-log-service.ts +++ b/backend/src/ee/services/audit-log/audit-log-service.ts @@ -22,7 +22,7 @@ export const auditLogServiceFactory = ({ auditLogQueue, permissionService }: TAuditLogServiceFactoryDep) => { - const listProjectAuditLogs = async ({ + const listAuditLogs = async ({ userAgentType, eventType, offset, @@ -36,14 +36,19 @@ export const auditLogServiceFactory = ({ projectId, auditLogActor }: TListProjectAuditLogDTO) => { - const { permission } = await permissionService.getProjectPermission( - actor, - actorId, - projectId, - actorAuthMethod, - actorOrgId - ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); + if (projectId) { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); + } + + // If project ID is not provided, then we need to return all the audit logs for the organization itself. + const auditLogs = await auditLogDAL.find({ startDate, endDate, @@ -52,8 +57,9 @@ export const auditLogServiceFactory = ({ eventType, userAgentType, actor: auditLogActor, - projectId + ...(projectId ? { projectId } : { orgId: actorOrgId }) }); + return auditLogs.map(({ eventType: logEventType, actor: eActor, actorMetadata, eventMetadata, ...el }) => ({ ...el, event: { type: logEventType, metadata: eventMetadata }, @@ -76,6 +82,6 @@ export const auditLogServiceFactory = ({ return { createAuditLog, - listProjectAuditLogs + listAuditLogs }; }; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 981b3777e..6202d30f2 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -6,14 +6,14 @@ import { PkiItemType } from "@app/services/pki-collection/pki-collection-types"; export type TListProjectAuditLogDTO = { auditLogActor?: string; - projectId: string; + projectId: string | null; eventType?: string; startDate?: string; endDate?: string; userAgentType?: string; limit?: number; offset?: number; -} & TProjectPermission; +} & Omit; export type TCreateAuditLogDTO = { event: Event; From 7b19d2aa6a1e93c354a394f80a1e48da38084a23 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Sun, 8 Sep 2024 19:24:04 +0400 Subject: [PATCH 05/17] feat: audit logs on organization-level support --- .../server/routes/v1/organization-router.ts | 68 ++++++++++++++++++- frontend/src/hooks/api/auditLogs/queries.tsx | 28 ++++---- frontend/src/hooks/api/auditLogs/types.tsx | 4 ++ 3 files changed, 86 insertions(+), 14 deletions(-) diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 808f125bb..2e49d5b8e 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { + AuditLogsSchema, GroupsSchema, IncidentContactsSchema, OrganizationsSchema, @@ -8,7 +9,9 @@ import { OrgRolesSchema, UsersSchema } from "@app/db/schemas"; -import { ORGANIZATIONS } from "@app/lib/api-docs"; +import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types"; +import { AUDIT_LOGS, ORGANIZATIONS } from "@app/lib/api-docs"; +import { getLastMidnightDateISO } from "@app/lib/fn"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -62,6 +65,69 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/audit-logs", + config: { + rateLimit: readLimit + }, + schema: { + description: "Get all audit logs for an organization", + querystring: z.object({ + eventType: z.nativeEnum(EventType).optional().describe(AUDIT_LOGS.EXPORT.eventType), + userAgentType: z.nativeEnum(UserAgentType).optional().describe(AUDIT_LOGS.EXPORT.userAgentType), + startDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.startDate), + endDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.endDate), + offset: z.coerce.number().default(0).describe(AUDIT_LOGS.EXPORT.offset), + limit: z.coerce.number().default(20).describe(AUDIT_LOGS.EXPORT.limit), + actor: z.string().optional().describe(AUDIT_LOGS.EXPORT.actor) + }), + + response: { + 200: z.object({ + auditLogs: AuditLogsSchema.omit({ + eventMetadata: true, + eventType: true, + actor: true, + actorMetadata: true + }) + .merge( + z.object({ + project: z.object({ + name: z.string(), + slug: z.string() + }), + event: z.object({ + type: z.string(), + metadata: z.any() + }), + actor: z.object({ + type: z.string(), + metadata: z.any() + }) + }) + ) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const auditLogs = await server.services.auditLog.listAuditLogs({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + ...req.query, + endDate: req.query.endDate, + startDate: req.query.startDate || getLastMidnightDateISO(), + auditLogActor: req.query.actor, + actor: req.permission.type, + projectId: null + }); + return { auditLogs }; + } + }); + server.route({ method: "GET", url: "/:organizationId/users", diff --git a/frontend/src/hooks/api/auditLogs/queries.tsx b/frontend/src/hooks/api/auditLogs/queries.tsx index 3517d4435..68fd3890c 100644 --- a/frontend/src/hooks/api/auditLogs/queries.tsx +++ b/frontend/src/hooks/api/auditLogs/queries.tsx @@ -5,27 +5,29 @@ import { apiRequest } from "@app/config/request"; import { Actor, AuditLog, AuditLogFilters } from "./types"; export const workspaceKeys = { - getAuditLogs: (workspaceId: string, filters: AuditLogFilters) => + getAuditLogs: (filters: AuditLogFilters, workspaceId: string | null) => [{ workspaceId, filters }, "audit-logs"] as const, getAuditLogActorFilterOpts: (workspaceId: string) => [{ workspaceId }, "audit-log-actor-filters"] as const }; -export const useGetAuditLogs = (workspaceId: string, filters: AuditLogFilters) => { +export const useGetAuditLogs = (filters: AuditLogFilters, workspaceId: string | null) => { return useInfiniteQuery({ - queryKey: workspaceKeys.getAuditLogs(workspaceId, filters), + queryKey: workspaceKeys.getAuditLogs(filters, workspaceId), + enabled: workspaceId !== "", + queryFn: async ({ pageParam }) => { - const { data } = await apiRequest.get<{ auditLogs: AuditLog[] }>( - `/api/v1/workspace/${workspaceId}/audit-logs`, - { - params: { - ...filters, - offset: pageParam, - startDate: filters?.startDate?.toISOString(), - endDate: filters?.endDate?.toISOString() - } + const auditLogEndpoint = workspaceId + ? `/api/v1/workspace/${workspaceId}/audit-logs` + : "/api/v1/organization/audit-logs"; + const { data } = await apiRequest.get<{ auditLogs: AuditLog[] }>(auditLogEndpoint, { + params: { + ...filters, + offset: pageParam, + startDate: filters?.startDate?.toISOString(), + endDate: filters?.endDate?.toISOString() } - ); + }); return data.auditLogs; }, getNextPageParam: (lastPage, pages) => diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index f9b53d037..5d300fdcb 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -830,6 +830,10 @@ export type AuditLog = { userAgentType: UserAgentType; createdAt: string; updatedAt: string; + project: { + name: string; + slug: string; + }; }; export type AuditLogFilters = { From 556ae168dd670cbd7110d109067c64d2e19529c4 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Sun, 8 Sep 2024 19:25:48 +0400 Subject: [PATCH 06/17] feat: fetch specific user group memberships --- backend/src/server/routes/index.ts | 1 + backend/src/server/routes/v1/user-router.ts | 51 +++++++- .../group-project/group-project-dal.ts | 112 +++++++++++++++++- backend/src/services/user/user-service.ts | 14 +++ 4 files changed, 176 insertions(+), 2 deletions(-) diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index f33456bd5..792bc8787 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -464,6 +464,7 @@ export const registerRoutes = async ( userAliasDAL, orgMembershipDAL, tokenService, + groupProjectDAL, smtpService, projectMembershipDAL }); diff --git a/backend/src/server/routes/v1/user-router.ts b/backend/src/server/routes/v1/user-router.ts index d3c0db242..944925a95 100644 --- a/backend/src/server/routes/v1/user-router.ts +++ b/backend/src/server/routes/v1/user-router.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; +import { ProjectsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; @@ -134,4 +134,53 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { ); } }); + + server.route({ + method: "GET", + url: "/me/:username/groups", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + username: z.string().trim() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string(), + slug: z.string(), + orgId: z.string(), + projectMemberships: z.array( + z.object({ + id: z.string(), + project: ProjectsSchema.pick({ id: true, name: true, slug: true }), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().nullable(), + customRoleName: z.string().nullable(), + customRoleSlug: z.string().nullable(), + temporaryRange: z.string().nullable(), + temporaryMode: z.string().nullable(), + temporaryAccessEndTime: z.string().nullable(), + temporaryAccessStartTime: z.string().nullable(), + isTemporary: z.boolean() + }) + ) + }) + ) + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const groupMemberships = await server.services.user.listUserGroups(req.params.username, req.permission.orgId); + + return groupMemberships; + } + }); }; diff --git a/backend/src/services/group-project/group-project-dal.ts b/backend/src/services/group-project/group-project-dal.ts index fedfcfbc3..b17aaee5b 100644 --- a/backend/src/services/group-project/group-project-dal.ts +++ b/backend/src/services/group-project/group-project-dal.ts @@ -95,6 +95,116 @@ export const groupProjectDALFactory = (db: TDbClient) => { } }; + const findByUserId = async (userId: string, orgId: string, tx?: Knex) => { + try { + const docs = await (tx || db.replicaNode())(TableName.UserGroupMembership) + .where(`${TableName.UserGroupMembership}.userId`, userId) + .join(TableName.Groups, function () { + this.on(`${TableName.UserGroupMembership}.groupId`, "=", `${TableName.Groups}.id`).andOn( + `${TableName.Groups}.orgId`, + "=", + db.raw("?", [orgId]) + ); + }) + .leftJoin( + TableName.GroupProjectMembership, + `${TableName.GroupProjectMembership}.groupId`, + `${TableName.Groups}.id` + ) + .leftJoin( + TableName.GroupProjectMembershipRole, + `${TableName.GroupProjectMembershipRole}.projectMembershipId`, + `${TableName.GroupProjectMembership}.id` + ) + .leftJoin( + TableName.ProjectRoles, + `${TableName.GroupProjectMembershipRole}.customRoleId`, + `${TableName.ProjectRoles}.id` + ) + .leftJoin(TableName.Project, `${TableName.GroupProjectMembership}.projectId`, `${TableName.Project}.id`) + .select( + db.ref("id").withSchema(TableName.Groups).as("groupId"), + db.ref("name").withSchema(TableName.Groups).as("groupName"), + db.ref("slug").withSchema(TableName.Groups).as("groupSlug"), + db.ref("orgId").withSchema(TableName.Groups), + db.ref("id").withSchema(TableName.GroupProjectMembership).as("projectMembershipId"), + db.ref("id").withSchema(TableName.Project).as("projectId"), + db.ref("name").withSchema(TableName.Project).as("projectName"), + db.ref("slug").withSchema(TableName.Project).as("projectSlug"), + db.ref("role").withSchema(TableName.GroupProjectMembershipRole), + db.ref("id").withSchema(TableName.GroupProjectMembershipRole).as("membershipRoleId"), + db.ref("customRoleId").withSchema(TableName.GroupProjectMembershipRole), + db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"), + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), + db.ref("temporaryMode").withSchema(TableName.GroupProjectMembershipRole), + db.ref("isTemporary").withSchema(TableName.GroupProjectMembershipRole), + db.ref("temporaryRange").withSchema(TableName.GroupProjectMembershipRole), + db.ref("temporaryAccessStartTime").withSchema(TableName.GroupProjectMembershipRole), + db.ref("temporaryAccessEndTime").withSchema(TableName.GroupProjectMembershipRole) + ); + + const groupsWithProjects = sqlNestRelationships({ + data: docs, + parentMapper: ({ groupId, groupName, groupSlug, orgId: organizationId }) => ({ + id: groupId, + name: groupName, + slug: groupSlug, + orgId: organizationId, + projectMemberships: [] + }), + key: "groupId", + childrenMapper: [ + { + label: "projectMemberships" as const, + key: "projectMembershipId", + mapper: ({ projectId, projectName, projectSlug, projectMembershipId }) => ({ + id: projectMembershipId, + project: { + id: projectId, + name: projectName, + slug: projectSlug + }, + roles: [] + }), + childrenMapper: [ + { + label: "roles" as const, + key: "membershipRoleId", + mapper: ({ + role, + customRoleId, + customRoleName, + customRoleSlug, + membershipRoleId, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) => ({ + id: membershipRoleId, + role, + customRoleId, + customRoleName, + customRoleSlug, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) + } + ] + } + ] + }); + + return groupsWithProjects; + } catch (error) { + throw new DatabaseError({ error, name: "FindByUserId" }); + } + }; + // The GroupProjectMembership table has a reference to the project (projectId) AND the group (groupId). // We need to join the GroupProjectMembership table with the Groups table to get the group name and slug. // We also need to join the GroupProjectMembershipRole table to get the role of the group in the project. @@ -197,5 +307,5 @@ export const groupProjectDALFactory = (db: TDbClient) => { return members; }; - return { ...groupProjectOrm, findByProjectId, findAllProjectGroupMembers }; + return { ...groupProjectOrm, findByProjectId, findByUserId, findAllProjectGroupMembers }; }; diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index f0b043279..97bbaecf0 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -8,6 +8,7 @@ import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { AuthMethod } from "../auth/auth-type"; +import { TGroupProjectDALFactory } from "../group-project/group-project-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TUserDALFactory } from "./user-dal"; @@ -27,6 +28,7 @@ type TUserServiceFactoryDep = { | "delete" >; userAliasDAL: Pick; + groupProjectDAL: Pick; orgMembershipDAL: Pick; tokenService: Pick; projectMembershipDAL: Pick; @@ -40,6 +42,7 @@ export const userServiceFactory = ({ userAliasDAL, orgMembershipDAL, projectMembershipDAL, + groupProjectDAL, tokenService, smtpService }: TUserServiceFactoryDep) => { @@ -295,6 +298,16 @@ export const userServiceFactory = ({ return updatedOrgMembership.projectFavorites; }; + const listUserGroups = async (username: string, orgId: string) => { + const user = await userDAL.findOne({ + username + }); + + const memberships = await groupProjectDAL.findByUserId(user.id, orgId); + + return memberships; + }; + return { sendEmailVerificationCode, verifyEmailVerificationCode, @@ -304,6 +317,7 @@ export const userServiceFactory = ({ deleteUser, getMe, createUserAction, + listUserGroups, getUserAction, unlockUser, getUserPrivateKey, From 6bdf3455f59f5f5ec3f8416187e691f91766196b Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Sun, 8 Sep 2024 19:27:31 +0400 Subject: [PATCH 07/17] Update mutations.tsx --- frontend/src/hooks/api/groups/mutations.tsx | 188 +++++++++----------- 1 file changed, 82 insertions(+), 106 deletions(-) diff --git a/frontend/src/hooks/api/groups/mutations.tsx b/frontend/src/hooks/api/groups/mutations.tsx index 32d718e37..878e8b1c7 100644 --- a/frontend/src/hooks/api/groups/mutations.tsx +++ b/frontend/src/hooks/api/groups/mutations.tsx @@ -3,128 +3,104 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { organizationKeys } from "../organization/queries"; +import { userKeys } from "../users/queries"; import { groupKeys } from "./queries"; import { TGroup } from "./types"; export const useCreateGroup = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async ({ - name, - slug, - role - }: { - name: string; - slug: string; - organizationId: string; - role?: string; - }) => { - const { - data: group - } = await apiRequest.post("/api/v1/groups", { - name, - slug, - role - }); - - return group; - }, - onSuccess: (_, { organizationId }) => { - queryClient.invalidateQueries(organizationKeys.getOrgGroups(organizationId)); - } - }); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + name, + slug, + role + }: { + name: string; + slug: string; + organizationId: string; + role?: string; + }) => { + const { data: group } = await apiRequest.post("/api/v1/groups", { + name, + slug, + role + }); + + return group; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgGroups(organizationId)); + } + }); }; export const useUpdateGroup = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async ({ - currentSlug, - name, - slug, - role - }: { - currentSlug: string; - name?: string; - slug?: string; - role?: string; - }) => { - const { - data: group - } = await apiRequest.patch(`/api/v1/groups/${currentSlug}`, { - name, - slug, - role - }); - - return group; - }, - onSuccess: ({ orgId }) => { - queryClient.invalidateQueries(organizationKeys.getOrgGroups(orgId)); - } - }); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + currentSlug, + name, + slug, + role + }: { + currentSlug: string; + name?: string; + slug?: string; + role?: string; + }) => { + const { data: group } = await apiRequest.patch(`/api/v1/groups/${currentSlug}`, { + name, + slug, + role + }); + + return group; + }, + onSuccess: ({ orgId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgGroups(orgId)); + } + }); }; export const useDeleteGroup = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async ({ - slug - }: { - slug: string; - }) => { - const { - data: group - } = await apiRequest.delete(`/api/v1/groups/${slug}`); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ slug }: { slug: string }) => { + const { data: group } = await apiRequest.delete(`/api/v1/groups/${slug}`); - return group; - }, - onSuccess: ({ orgId }) => { - queryClient.invalidateQueries(organizationKeys.getOrgGroups(orgId)); - } - }); + return group; + }, + onSuccess: ({ orgId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgGroups(orgId)); + } + }); }; export const useAddUserToGroup = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async ({ - slug, - username - }: { - slug: string; - username: string; - }) => { - const { - data - } = await apiRequest.post(`/api/v1/groups/${slug}/users/${username}`); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ slug, username }: { slug: string; username: string }) => { + const { data } = await apiRequest.post(`/api/v1/groups/${slug}/users/${username}`); - return data; - }, - onSuccess: (_, { slug }) => { - queryClient.invalidateQueries(groupKeys.forGroupUserMemberships(slug)); - } - }); + return data; + }, + onSuccess: (_, { slug }) => { + queryClient.invalidateQueries(groupKeys.forGroupUserMemberships(slug)); + } + }); }; export const useRemoveUserFromGroup = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async ({ - slug, - username - }: { - slug: string; - username: string; - }) => { - const { - data - } = await apiRequest.delete(`/api/v1/groups/${slug}/users/${username}`); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ slug, username }: { slug: string; username: string }) => { + const { data } = await apiRequest.delete(`/api/v1/groups/${slug}/users/${username}`); - return data; - }, - onSuccess: (_, { slug }) => { - queryClient.invalidateQueries(groupKeys.forGroupUserMemberships(slug)); - } - }); -}; \ No newline at end of file + return data; + }, + onSuccess: (_, { slug, username }) => { + queryClient.invalidateQueries(groupKeys.forGroupUserMemberships(slug)); + queryClient.invalidateQueries(userKeys.listUserGroupMemberships(username)); + } + }); +}; From 7846a8163638b4d8366011c25e21da42f47bebab Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Sun, 8 Sep 2024 19:28:17 +0400 Subject: [PATCH 08/17] chore: new group with project memberships type --- frontend/src/hooks/api/groups/types.ts | 31 ++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/frontend/src/hooks/api/groups/types.ts b/frontend/src/hooks/api/groups/types.ts index 3db2357cd..126ca56d4 100644 --- a/frontend/src/hooks/api/groups/types.ts +++ b/frontend/src/hooks/api/groups/types.ts @@ -4,7 +4,7 @@ import { TOrgRole } from "../roles/types"; export type TGroupOrgMembership = TGroup & { customRole?: TOrgRole; -} +}; export type TGroup = { id: string; @@ -33,4 +33,31 @@ export type TGroupMembership = { }[]; createdAt: string; updatedAt: string; -}; \ No newline at end of file +}; + +export type TGroupWithProjectMemberships = { + id: string; + name: string; + slug: string; + orgId: string; + projectMemberships: { + id: string; + project: { + id: string; + name: string; + slug: string; + }; + roles: { + id: string; + role: string; + customRoleId: string | null; + customRoleName: string | null; + customRoleSlug: string | null; + temporaryRange: string | null; + temporaryMode: string | null; + temporaryAccessEndTime: string | null; + temporaryAccessStartTime: string | null; + isTemporary: boolean; + }[]; + }[]; +}; From 34a2452bf5d745393bcb8c8930378f0c2aaa6e9d Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Sun, 8 Sep 2024 20:19:10 +0400 Subject: [PATCH 09/17] feat: fetch all user group memberships --- frontend/src/hooks/api/users/index.tsx | 1 + frontend/src/hooks/api/users/queries.tsx | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index ed9e5cc47..fb4c2d1ce 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -21,6 +21,7 @@ export { useGetOrgUsers, useGetUser, useGetUserAction, + useListUserGroupMemberships, useLogoutUser, useRegisterUserAction, useRevokeMySessions, diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index ead567bee..27878f624 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -5,6 +5,7 @@ import { SessionStorageKeys } from "@app/const"; import { setAuthToken } from "@app/reactQuery"; import { APIKeyDataV2 } from "../apiKeys/types"; +import { TGroupWithProjectMemberships } from "../groups/types"; import { AddUserToOrgDTO, APIKeyData, @@ -38,6 +39,7 @@ export const userKeys = { myAPIKeysV2: ["api-keys-v2"] as const, mySessions: ["sessions"] as const, listUsers: ["user-list"] as const, + listUserGroupMemberships: (username: string) => ["user-group-memberships", username] as const, myOrganizationProjects: (orgId: string) => [{ orgId }, "organization-projects"] as const }; @@ -444,3 +446,16 @@ export const fetchMyPrivateKey = async () => { return privateKey; }; + +export const useListUserGroupMemberships = (username: string) => { + return useQuery({ + queryKey: userKeys.listUserGroupMemberships(username), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/user/me/${username}/groups` + ); + + return data; + } + }); +}; From 655ee4f11805037325d99e547eae6688a0199e07 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Sun, 8 Sep 2024 20:19:50 +0400 Subject: [PATCH 10/17] Update mutations.tsx --- frontend/src/hooks/api/workspace/mutations.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/frontend/src/hooks/api/workspace/mutations.tsx b/frontend/src/hooks/api/workspace/mutations.tsx index 3b12c9210..5ac54a089 100644 --- a/frontend/src/hooks/api/workspace/mutations.tsx +++ b/frontend/src/hooks/api/workspace/mutations.tsx @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { userKeys } from "../users/queries"; import { workspaceKeys } from "./queries"; import { TUpdateWorkspaceGroupRoleDTO } from "./types"; @@ -51,14 +52,25 @@ export const useUpdateGroupWorkspaceRole = () => { export const useDeleteGroupFromWorkspace = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ groupSlug, projectSlug }: { groupSlug: string; projectSlug: string }) => { + mutationFn: async ({ + groupSlug, + projectSlug + }: { + groupSlug: string; + projectSlug: string; + username?: string; + }) => { const { data: { groupMembership } } = await apiRequest.delete(`/api/v2/workspace/${projectSlug}/groups/${groupSlug}`); return groupMembership; }, - onSuccess: (_, { projectSlug }) => { + onSuccess: (_, { projectSlug, username }) => { queryClient.invalidateQueries(workspaceKeys.getWorkspaceGroupMemberships(projectSlug)); + + if (username) { + queryClient.invalidateQueries(userKeys.listUserGroupMemberships(username)); + } } }); }; From e1cd632546a5faf6f0d71c8b68610bd19e675955 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Sun, 8 Sep 2024 20:20:10 +0400 Subject: [PATCH 11/17] improvements to user group ui --- .../UserGroupProjectsModal.tsx | 160 +++++++++++++++++ .../UserProjectsSection/UserGroupsRow.tsx | 169 ++---------------- .../UserProjectsSection/UserGroupsSection.tsx | 6 +- .../UserProjectsSection/UserGroupsTable.tsx | 6 +- 4 files changed, 189 insertions(+), 152 deletions(-) create mode 100644 frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupProjectsModal.tsx diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupProjectsModal.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupProjectsModal.tsx new file mode 100644 index 000000000..b00d44df1 --- /dev/null +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupProjectsModal.tsx @@ -0,0 +1,160 @@ +import { useCallback, useMemo, useState } from "react"; +import Link from "next/link"; +import { faFolder, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + EmptyState, + Input, + Modal, + ModalContent, + Table, + TableContainer, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { useDeleteGroupFromWorkspace } from "@app/hooks/api"; +import { TGroupWithProjectMemberships } from "@app/hooks/api/groups/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +export type UserGroupsProjectsModalData = { + group: TGroupWithProjectMemberships; + groupSlug: string; + username: string; +}; + +export const UserGroupsProjectsModal = ({ + popUp, + handlePopUpToggle +}: { + popUp: UsePopUpState<["userGroupsProjects"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["userGroupsProjects"]>, + state?: boolean + ) => void; +}) => { + const popupData = popUp.userGroupsProjects.data as UserGroupsProjectsModalData; + + const [filteredProjectName, setFilteredProjectName] = useState(""); + + const { mutateAsync: deleteGroupFromProject } = useDeleteGroupFromWorkspace(); + + const filteredMemberships = useMemo(() => { + if (!popupData?.group) return []; + + return popupData.group.projectMemberships.filter(({ project }) => { + return project.name.toLowerCase().includes(filteredProjectName.toLowerCase()); + }); + }, [filteredProjectName, popupData?.group?.projectMemberships]); + + const removeProjectFromGroup = useCallback( + async (projectSlug: string) => { + await deleteGroupFromProject({ + groupSlug: popupData.groupSlug, + projectSlug, + username: popupData.username + }); + + createNotification({ + type: "success", + text: "Project removed from group" + }); + + // Manually remove the project from the list, since the query invalidation won't trigger the popup data to be updated + popupData.group.projectMemberships = popupData.group.projectMemberships.filter( + (membership) => membership.project.slug !== projectSlug + ); + }, + [popupData] + ); + + return ( + { + handlePopUpToggle("userGroupsProjects", open); + }} + > + + setFilteredProjectName(e.target.value)} + leftIcon={} + placeholder="Search projects..." + /> + + + + + + + + + {filteredMemberships?.map(({ project, roles }) => { + return ( + + + + + ); + })} + +
Project
+ +

+ {project.name} +

+ + {/* If there's only one role, display the role.name. If more, display role.name (+X more) */} + role.role.charAt(0).toUpperCase() + role.role.slice(1)) + .join(", ")} + > +

+ {roles.length === 1 + ? roles[0].role + : `${roles[0].role} (+${roles.length - 1} more)`} +

+
+
+ + {(isAllowed) => { + return ( +
+ +
+ ); + }} +
+
+ {!filteredMemberships.length && } +
+
+
+ ); +}; diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsRow.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsRow.tsx index 3e99d8e78..d3de7919a 100644 --- a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsRow.tsx +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsRow.tsx @@ -1,161 +1,36 @@ -import { useCallback, useMemo, useState } from "react"; -import Link from "next/link"; -import { faFolder, faMagnifyingGlass, faTrash } from "@fortawesome/free-solid-svg-icons"; +/* eslint-disable react/jsx-no-useless-fragment */ +import { faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { createNotification } from "@app/components/notifications"; -import { OrgPermissionCan } from "@app/components/permissions"; -import { - Button, - EmptyState, - IconButton, - Input, - Modal, - ModalContent, - Table, - TableContainer, - TBody, - Td, - Th, - THead, - Tooltip, - Tr -} from "@app/components/v2"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; -import { useDeleteGroupFromWorkspace } from "@app/hooks/api"; +import { IconButton, Td, Tooltip, Tr } from "@app/components/v2"; import { TGroupWithProjectMemberships } from "@app/hooks/api/groups/types"; +import { OrgUser } from "@app/hooks/api/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; +import { UserGroupsProjectsModalData } from "./UserGroupProjectsModal"; + type Props = { group: TGroupWithProjectMemberships; - handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromGroup"]>, data?: {}) => void; + orgMembership: OrgUser; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["removeUserFromGroup", "userGroupsProjects"]>, + data?: {} + ) => void; }; -const GroupProjectsModal = ({ - isOpen, - group, - groupSlug, - setIsOpen -}: { - setIsOpen: (open: boolean) => void; - isOpen: boolean; - group: TGroupWithProjectMemberships; - groupSlug: string; -}) => { - const [filteredProjectName, setFilteredProjectName] = useState(""); - - const { mutateAsync: deleteGroupFromProject } = useDeleteGroupFromWorkspace(); - - const filteredMemberships = useMemo(() => { - return group.projectMemberships.filter(({ project }) => { - return project.name.toLowerCase().includes(filteredProjectName.toLowerCase()); - }); - }, [filteredProjectName, group]); - - const removeProjectFromGroup = useCallback(async (projectSlug: string) => { - await deleteGroupFromProject({ - groupSlug, - projectSlug - }); - - createNotification({ - type: "success", - text: "Project removed from group" - }); - }, []); - - return ( - { - setIsOpen(open); - }} - > - - setFilteredProjectName(e.target.value)} - leftIcon={} - placeholder="Search projects..." - /> - - - - - - - - - {filteredMemberships?.map(({ project, roles }) => { - return ( - - - - - ); - })} - -
Project
- -

- {project.name} -

- - {/* If there's only one role, display the role.name. If more, display role.name (+X more) */} - role.role.charAt(0).toUpperCase() + role.role.slice(1)) - .join(", ")} - > -

- {roles.length === 1 - ? roles[0].role - : `${roles[0].role} (+${roles.length - 1} more)`} -

-
-
- - {(isAllowed) => { - return ( -
- -
- ); - }} -
-
- {!filteredMemberships.length && } -
-
-
- ); -}; - -export const UserGroupsRow = ({ group, handlePopUpOpen }: Props) => { - const [isProjectsModalOpen, setIsProjectsModalOpen] = useState(false); - +export const UserGroupsRow = ({ group, orgMembership, handlePopUpOpen }: Props) => { return ( <> setIsProjectsModalOpen(true)} + onClick={() => { + handlePopUpOpen("userGroupsProjects", { + groupSlug: group.slug, + username: orgMembership.user.username, + group + } as UserGroupsProjectsModalData); + }} > {group.name} @@ -185,12 +60,6 @@ export const UserGroupsRow = ({ group, handlePopUpOpen }: Props) => { )} - ); }; diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsSection.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsSection.tsx index 41340aec4..ace999286 100644 --- a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsSection.tsx +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsSection.tsx @@ -6,6 +6,7 @@ import { useRemoveUserFromGroup } from "@app/hooks/api"; import { OrgUser } from "@app/hooks/api/users/types"; import { usePopUp } from "@app/hooks/usePopUp"; +import { UserGroupsProjectsModal } from "./UserGroupProjectsModal"; import { UserGroupsTable } from "./UserGroupsTable"; type Props = { @@ -14,7 +15,8 @@ type Props = { export const UserGroupsSection = ({ orgMembership }: Props) => { const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ - "removeUserFromGroup" + "removeUserFromGroup", + "userGroupsProjects" ] as const); const { mutateAsync: removeUserFromGroup } = useRemoveUserFromGroup(); @@ -63,6 +65,8 @@ export const UserGroupsSection = ({ orgMembership }: Props) => { return handleRemoveUserFromGroup(popupData.groupSlug); }} /> + + ); }; diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx index 914df9985..7cf1ff225 100644 --- a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx @@ -9,7 +9,10 @@ import { UserGroupsRow } from "./UserGroupsRow"; type Props = { orgMembership: OrgUser; - handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromGroup"]>, data?: {}) => void; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["removeUserFromGroup", "userGroupsProjects"]>, + data?: {} + ) => void; }; export const UserGroupsTable = ({ handlePopUpOpen, orgMembership }: Props) => { @@ -28,6 +31,7 @@ export const UserGroupsTable = ({ handlePopUpOpen, orgMembership }: Props) => { {groups?.map((group) => ( Date: Sun, 8 Sep 2024 20:20:34 +0400 Subject: [PATCH 12/17] feat: better user visualization --- frontend/src/views/Org/UserPage/UserPage.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/frontend/src/views/Org/UserPage/UserPage.tsx b/frontend/src/views/Org/UserPage/UserPage.tsx index f1ac3fae8..5c10e1977 100644 --- a/frontend/src/views/Org/UserPage/UserPage.tsx +++ b/frontend/src/views/Org/UserPage/UserPage.tsx @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { useRouter } from "next/router"; -import { faChevronLeft, faEllipsis } from "@fortawesome/free-solid-svg-icons"; +import { faChevronLeft, faEllipsis, faFolder, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; @@ -31,6 +31,8 @@ import { import { usePopUp } from "@app/hooks/usePopUp"; import { TabSections } from "@app/views/Org/Types"; +import { UserAuditLogsSection } from "./components/UserProjectsSection/UserAuditLogsSection"; +import { UserGroupsSection } from "./components/UserProjectsSection/UserGroupsSection"; import { UserDetailsSection, UserOrgMembershipModal, UserProjectsSection } from "./components"; export const UserPage = withPermission( @@ -241,7 +243,13 @@ export const UserPage = withPermission(
- +
+
+ + + +
+
)} @@ -272,6 +280,7 @@ export const UserPage = withPermission( } buttonText="Deactivate" /> + handlePopUpToggle("upgradePlan", isOpen)} From 26add7bfd11690e6f9c30f6d471169462a7fe992 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Mon, 9 Sep 2024 10:42:10 +0400 Subject: [PATCH 13/17] fix: remove delete project membership option --- .../UserGroupProjectsModal.tsx | 69 +++---------------- 1 file changed, 10 insertions(+), 59 deletions(-) diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupProjectsModal.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupProjectsModal.tsx index b00d44df1..f0d021292 100644 --- a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupProjectsModal.tsx +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupProjectsModal.tsx @@ -1,12 +1,9 @@ -import { useCallback, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import Link from "next/link"; import { faFolder, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { createNotification } from "@app/components/notifications"; -import { OrgPermissionCan } from "@app/components/permissions"; import { - Button, EmptyState, Input, Modal, @@ -20,8 +17,6 @@ import { Tooltip, Tr } from "@app/components/v2"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; -import { useDeleteGroupFromWorkspace } from "@app/hooks/api"; import { TGroupWithProjectMemberships } from "@app/hooks/api/groups/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -45,8 +40,6 @@ export const UserGroupsProjectsModal = ({ const [filteredProjectName, setFilteredProjectName] = useState(""); - const { mutateAsync: deleteGroupFromProject } = useDeleteGroupFromWorkspace(); - const filteredMemberships = useMemo(() => { if (!popupData?.group) return []; @@ -55,27 +48,6 @@ export const UserGroupsProjectsModal = ({ }); }, [filteredProjectName, popupData?.group?.projectMemberships]); - const removeProjectFromGroup = useCallback( - async (projectSlug: string) => { - await deleteGroupFromProject({ - groupSlug: popupData.groupSlug, - projectSlug, - username: popupData.username - }); - - createNotification({ - type: "success", - text: "Project removed from group" - }); - - // Manually remove the project from the list, since the query invalidation won't trigger the popup data to be updated - popupData.group.projectMemberships = popupData.group.projectMemberships.filter( - (membership) => membership.project.slug !== projectSlug - ); - }, - [popupData] - ); - return ( - + setFilteredProjectName(e.target.value)} @@ -95,58 +70,34 @@ export const UserGroupsProjectsModal = ({ Project + Roles {filteredMemberships?.map(({ project, roles }) => { return ( - +

{project.name}

- {/* If there's only one role, display the role.name. If more, display role.name (+X more) */} + + role.role.charAt(0).toUpperCase() + role.role.slice(1)) .join(", ")} > -

+

{roles.length === 1 ? roles[0].role : `${roles[0].role} (+${roles.length - 1} more)`}

- - - {(isAllowed) => { - return ( -
- -
- ); - }} -
- ); })} From 372b6cbaea2a0bee25367323abe1cfb78a96af1b Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Mon, 9 Sep 2024 10:42:39 +0400 Subject: [PATCH 14/17] fix: audit log fixes --- frontend/src/views/Project/AuditLogsPage/AuditLogsPage.tsx | 2 +- .../views/Project/AuditLogsPage/components/LogsSection.tsx | 1 + .../src/views/Project/AuditLogsPage/components/LogsTable.tsx | 5 ++++- .../views/Project/AuditLogsPage/components/LogsTableRow.tsx | 5 +++-- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/frontend/src/views/Project/AuditLogsPage/AuditLogsPage.tsx b/frontend/src/views/Project/AuditLogsPage/AuditLogsPage.tsx index d9f95f2d5..e9b8504a2 100644 --- a/frontend/src/views/Project/AuditLogsPage/AuditLogsPage.tsx +++ b/frontend/src/views/Project/AuditLogsPage/AuditLogsPage.tsx @@ -12,7 +12,7 @@ export const AuditLogsPage = withProjectPermission(

Audit Logs

- +
); diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsSection.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsSection.tsx index 8df4338b6..aeb12468d 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsSection.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/LogsSection.tsx @@ -68,6 +68,7 @@ export const LogsSection = ({ isOrgAuditLogs={isOrgAuditLogs} eventType={eventType} userAgentType={userAgentType} + showActorColumn={!presetActor} actor={actor} startDate={startDate} endDate={endDate} diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsTable.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsTable.tsx index 22022ee67..3a628a0d0 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsTable.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/LogsTable.tsx @@ -26,6 +26,7 @@ type Props = { startDate?: Date; endDate?: Date; isOrgAuditLogs?: boolean; + showActorColumn: boolean; }; const AUDIT_LOG_LIMIT = 15; @@ -33,6 +34,7 @@ const AUDIT_LOG_LIMIT = 15; export const LogsTable = ({ eventType, userAgentType, + showActorColumn, actor, startDate, endDate, @@ -63,7 +65,7 @@ export const LogsTable = ({ Timestamp Event {isOrgAuditLogs && Project} - Actor + {showActorColumn && Actor} Source Metadata @@ -74,6 +76,7 @@ export const LogsTable = ({ {group.map((auditLog) => ( { +export const LogsTableRow = ({ auditLog, isOrgAuditLogs, showActorColumn }: Props) => { const renderActor = (actor: Actor) => { switch (actor.type) { case ActorType.USER: @@ -471,7 +472,7 @@ export const LogsTableRow = ({ auditLog, isOrgAuditLogs }: Props) => { {formatDate(auditLog.createdAt)} {`${eventToNameMap[auditLog.event.type]}`} {isOrgAuditLogs && {auditLog.project.name}} - {renderActor(auditLog.actor)} + {showActorColumn && renderActor(auditLog.actor)}

{userAgentTTypeoNameMap[auditLog.userAgentType]}

{auditLog.ipAddress}

From 4dda270e8ee725419fe30e3bfb4c7ed4e54e20b7 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Tue, 10 Sep 2024 23:29:23 +0400 Subject: [PATCH 15/17] Requested changes --- .../UserAuditLogsSection.tsx | 43 ++++++- .../UserGroupProjectsModal.tsx | 111 ------------------ .../UserProjectsSection/UserGroupsRow.tsx | 22 +--- .../UserProjectsSection/UserGroupsSection.tsx | 6 +- .../UserProjectsSection/UserGroupsTable.tsx | 7 +- 5 files changed, 43 insertions(+), 146 deletions(-) delete mode 100644 frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupProjectsModal.tsx diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAuditLogsSection.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAuditLogsSection.tsx index 16540a8cf..1736e8333 100644 --- a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAuditLogsSection.tsx +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAuditLogsSection.tsx @@ -1,9 +1,10 @@ import { useState } from "react"; +import Link from "next/link"; import { faFilter } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { IconButton, Tooltip } from "@app/components/v2"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { EmptyState, IconButton, Tooltip } from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context"; import { withPermission } from "@app/hoc"; import { OrgUser } from "@app/hooks/api/types"; import { LogsSection } from "@app/views/Project/AuditLogsPage/components"; @@ -15,8 +16,10 @@ type Props = { export const UserAuditLogsSection = withPermission( ({ orgMembership }: Props) => { const [showFilter, setShowFilter] = useState(false); + const { subscription, isLoading } = useSubscription(); - return ( + // eslint-disable-next-line no-nested-ternary + return subscription?.auditLogs ? (

Audit Logs

@@ -43,7 +46,39 @@ export const UserAuditLogsSection = withPermission( isOrgAuditLogs />
- ); + ) : !isLoading ? ( +
+
+

Audit Logs

+
+ +

+ Please{" "} + + + upgrade your subscription + + {" "} + to view audit logs. +

+
+ } + /> +
+ ) : null; }, { action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Member } ); diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupProjectsModal.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupProjectsModal.tsx deleted file mode 100644 index f0d021292..000000000 --- a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupProjectsModal.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { useMemo, useState } from "react"; -import Link from "next/link"; -import { faFolder, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { - EmptyState, - Input, - Modal, - ModalContent, - Table, - TableContainer, - TBody, - Td, - Th, - THead, - Tooltip, - Tr -} from "@app/components/v2"; -import { TGroupWithProjectMemberships } from "@app/hooks/api/groups/types"; -import { UsePopUpState } from "@app/hooks/usePopUp"; - -export type UserGroupsProjectsModalData = { - group: TGroupWithProjectMemberships; - groupSlug: string; - username: string; -}; - -export const UserGroupsProjectsModal = ({ - popUp, - handlePopUpToggle -}: { - popUp: UsePopUpState<["userGroupsProjects"]>; - handlePopUpToggle: ( - popUpName: keyof UsePopUpState<["userGroupsProjects"]>, - state?: boolean - ) => void; -}) => { - const popupData = popUp.userGroupsProjects.data as UserGroupsProjectsModalData; - - const [filteredProjectName, setFilteredProjectName] = useState(""); - - const filteredMemberships = useMemo(() => { - if (!popupData?.group) return []; - - return popupData.group.projectMemberships.filter(({ project }) => { - return project.name.toLowerCase().includes(filteredProjectName.toLowerCase()); - }); - }, [filteredProjectName, popupData?.group?.projectMemberships]); - - return ( - { - handlePopUpToggle("userGroupsProjects", open); - }} - > - - setFilteredProjectName(e.target.value)} - leftIcon={} - placeholder="Search projects..." - /> - - - - - - - - - - {filteredMemberships?.map(({ project, roles }) => { - return ( - - - - - ); - })} - -
ProjectRoles
- -

- {project.name} -

- -
- role.role.charAt(0).toUpperCase() + role.role.slice(1)) - .join(", ")} - > -

- {roles.length === 1 - ? roles[0].role - : `${roles[0].role} (+${roles.length - 1} more)`} -

-
-
- {!filteredMemberships.length && } -
-
-
- ); -}; diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsRow.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsRow.tsx index d3de7919a..712f8f0be 100644 --- a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsRow.tsx +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsRow.tsx @@ -4,39 +4,21 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { IconButton, Td, Tooltip, Tr } from "@app/components/v2"; import { TGroupWithProjectMemberships } from "@app/hooks/api/groups/types"; -import { OrgUser } from "@app/hooks/api/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; -import { UserGroupsProjectsModalData } from "./UserGroupProjectsModal"; - type Props = { group: TGroupWithProjectMemberships; - orgMembership: OrgUser; - handlePopUpOpen: ( - popUpName: keyof UsePopUpState<["removeUserFromGroup", "userGroupsProjects"]>, - data?: {} - ) => void; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromGroup"]>, data?: {}) => void; }; -export const UserGroupsRow = ({ group, orgMembership, handlePopUpOpen }: Props) => { +export const UserGroupsRow = ({ group, handlePopUpOpen }: Props) => { return ( <> { - handlePopUpOpen("userGroupsProjects", { - groupSlug: group.slug, - username: orgMembership.user.username, - group - } as UserGroupsProjectsModalData); - }} > {group.name} - - {group.projectMemberships.length} Project - {group.projectMemberships.length > 1 || group.projectMemberships.length === 0 ? "s" : ""} - {true && (
diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsSection.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsSection.tsx index ace999286..41340aec4 100644 --- a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsSection.tsx +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsSection.tsx @@ -6,7 +6,6 @@ import { useRemoveUserFromGroup } from "@app/hooks/api"; import { OrgUser } from "@app/hooks/api/users/types"; import { usePopUp } from "@app/hooks/usePopUp"; -import { UserGroupsProjectsModal } from "./UserGroupProjectsModal"; import { UserGroupsTable } from "./UserGroupsTable"; type Props = { @@ -15,8 +14,7 @@ type Props = { export const UserGroupsSection = ({ orgMembership }: Props) => { const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ - "removeUserFromGroup", - "userGroupsProjects" + "removeUserFromGroup" ] as const); const { mutateAsync: removeUserFromGroup } = useRemoveUserFromGroup(); @@ -65,8 +63,6 @@ export const UserGroupsSection = ({ orgMembership }: Props) => { return handleRemoveUserFromGroup(popupData.groupSlug); }} /> - - ); }; diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx index 7cf1ff225..15299da26 100644 --- a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx @@ -9,10 +9,7 @@ import { UserGroupsRow } from "./UserGroupsRow"; type Props = { orgMembership: OrgUser; - handlePopUpOpen: ( - popUpName: keyof UsePopUpState<["removeUserFromGroup", "userGroupsProjects"]>, - data?: {} - ) => void; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromGroup"]>, data?: {}) => void; }; export const UserGroupsTable = ({ handlePopUpOpen, orgMembership }: Props) => { @@ -24,14 +21,12 @@ export const UserGroupsTable = ({ handlePopUpOpen, orgMembership }: Props) => { Name - Projects {groups?.map((group) => ( Date: Wed, 11 Sep 2024 12:45:14 +0400 Subject: [PATCH 16/17] Requested changes --- .../services/audit-log/audit-log-service.ts | 13 ++- .../ee/services/audit-log/audit-log-types.ts | 2 +- backend/src/server/routes/index.ts | 1 + .../server/routes/v1/organization-router.ts | 3 +- backend/src/server/routes/v1/user-router.ts | 32 ++----- .../group-project/group-project-dal.ts | 96 +------------------ backend/src/services/user/user-service.ts | 24 ++++- backend/src/services/user/user-types.ts | 5 + frontend/src/hooks/api/groups/types.ts | 20 ---- .../UserProjectsSection/UserGroupsRow.tsx | 38 ++++---- .../UserProjectsSection/UserGroupsSection.tsx | 2 +- 11 files changed, 74 insertions(+), 162 deletions(-) diff --git a/backend/src/ee/services/audit-log/audit-log-service.ts b/backend/src/ee/services/audit-log/audit-log-service.ts index 2fc7e0de3..b730f5ee7 100644 --- a/backend/src/ee/services/audit-log/audit-log-service.ts +++ b/backend/src/ee/services/audit-log/audit-log-service.ts @@ -3,6 +3,7 @@ import { ForbiddenError } from "@casl/ability"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; +import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; import { TAuditLogDALFactory } from "./audit-log-dal"; @@ -11,7 +12,7 @@ import { EventType, TCreateAuditLogDTO, TListProjectAuditLogDTO } from "./audit- type TAuditLogServiceFactoryDep = { auditLogDAL: TAuditLogDALFactory; - permissionService: Pick; + permissionService: Pick; auditLogQueue: TAuditLogQueueServiceFactory; }; @@ -45,6 +46,16 @@ export const auditLogServiceFactory = ({ actorOrgId ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); + } else { + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); } // If project ID is not provided, then we need to return all the audit logs for the organization itself. diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 6202d30f2..c9d04602d 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -6,7 +6,7 @@ import { PkiItemType } from "@app/services/pki-collection/pki-collection-types"; export type TListProjectAuditLogDTO = { auditLogActor?: string; - projectId: string | null; + projectId?: string; eventType?: string; startDate?: string; endDate?: string; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 792bc8787..70e7639b2 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -464,6 +464,7 @@ export const registerRoutes = async ( userAliasDAL, orgMembershipDAL, tokenService, + permissionService, groupProjectDAL, smtpService, projectMembershipDAL diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 2e49d5b8e..68a1dba45 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -121,8 +121,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { endDate: req.query.endDate, startDate: req.query.startDate || getLastMidnightDateISO(), auditLogActor: req.query.actor, - actor: req.permission.type, - projectId: null + actor: req.permission.type }); return { auditLogs }; } diff --git a/backend/src/server/routes/v1/user-router.ts b/backend/src/server/routes/v1/user-router.ts index 944925a95..4e4583196 100644 --- a/backend/src/server/routes/v1/user-router.ts +++ b/backend/src/server/routes/v1/user-router.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { ProjectsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; +import { UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; @@ -151,34 +151,20 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { id: z.string(), name: z.string(), slug: z.string(), - orgId: z.string(), - projectMemberships: z.array( - z.object({ - id: z.string(), - project: ProjectsSchema.pick({ id: true, name: true, slug: true }), - roles: z.array( - z.object({ - id: z.string(), - role: z.string(), - customRoleId: z.string().nullable(), - customRoleName: z.string().nullable(), - customRoleSlug: z.string().nullable(), - temporaryRange: z.string().nullable(), - temporaryMode: z.string().nullable(), - temporaryAccessEndTime: z.string().nullable(), - temporaryAccessStartTime: z.string().nullable(), - isTemporary: z.boolean() - }) - ) - }) - ) + orgId: z.string() }) .array() } }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const groupMemberships = await server.services.user.listUserGroups(req.params.username, req.permission.orgId); + const groupMemberships = await server.services.user.listUserGroups({ + username: req.params.username, + actorOrgId: req.permission.orgId, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type + }); return groupMemberships; } diff --git a/backend/src/services/group-project/group-project-dal.ts b/backend/src/services/group-project/group-project-dal.ts index b17aaee5b..c74a6b1b2 100644 --- a/backend/src/services/group-project/group-project-dal.ts +++ b/backend/src/services/group-project/group-project-dal.ts @@ -106,100 +106,14 @@ export const groupProjectDALFactory = (db: TDbClient) => { db.raw("?", [orgId]) ); }) - .leftJoin( - TableName.GroupProjectMembership, - `${TableName.GroupProjectMembership}.groupId`, - `${TableName.Groups}.id` - ) - .leftJoin( - TableName.GroupProjectMembershipRole, - `${TableName.GroupProjectMembershipRole}.projectMembershipId`, - `${TableName.GroupProjectMembership}.id` - ) - .leftJoin( - TableName.ProjectRoles, - `${TableName.GroupProjectMembershipRole}.customRoleId`, - `${TableName.ProjectRoles}.id` - ) - .leftJoin(TableName.Project, `${TableName.GroupProjectMembership}.projectId`, `${TableName.Project}.id`) .select( - db.ref("id").withSchema(TableName.Groups).as("groupId"), - db.ref("name").withSchema(TableName.Groups).as("groupName"), - db.ref("slug").withSchema(TableName.Groups).as("groupSlug"), - db.ref("orgId").withSchema(TableName.Groups), - db.ref("id").withSchema(TableName.GroupProjectMembership).as("projectMembershipId"), - db.ref("id").withSchema(TableName.Project).as("projectId"), - db.ref("name").withSchema(TableName.Project).as("projectName"), - db.ref("slug").withSchema(TableName.Project).as("projectSlug"), - db.ref("role").withSchema(TableName.GroupProjectMembershipRole), - db.ref("id").withSchema(TableName.GroupProjectMembershipRole).as("membershipRoleId"), - db.ref("customRoleId").withSchema(TableName.GroupProjectMembershipRole), - db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"), - db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), - db.ref("temporaryMode").withSchema(TableName.GroupProjectMembershipRole), - db.ref("isTemporary").withSchema(TableName.GroupProjectMembershipRole), - db.ref("temporaryRange").withSchema(TableName.GroupProjectMembershipRole), - db.ref("temporaryAccessStartTime").withSchema(TableName.GroupProjectMembershipRole), - db.ref("temporaryAccessEndTime").withSchema(TableName.GroupProjectMembershipRole) + db.ref("id").withSchema(TableName.Groups), + db.ref("name").withSchema(TableName.Groups), + db.ref("slug").withSchema(TableName.Groups), + db.ref("orgId").withSchema(TableName.Groups) ); - const groupsWithProjects = sqlNestRelationships({ - data: docs, - parentMapper: ({ groupId, groupName, groupSlug, orgId: organizationId }) => ({ - id: groupId, - name: groupName, - slug: groupSlug, - orgId: organizationId, - projectMemberships: [] - }), - key: "groupId", - childrenMapper: [ - { - label: "projectMemberships" as const, - key: "projectMembershipId", - mapper: ({ projectId, projectName, projectSlug, projectMembershipId }) => ({ - id: projectMembershipId, - project: { - id: projectId, - name: projectName, - slug: projectSlug - }, - roles: [] - }), - childrenMapper: [ - { - label: "roles" as const, - key: "membershipRoleId", - mapper: ({ - role, - customRoleId, - customRoleName, - customRoleSlug, - membershipRoleId, - temporaryRange, - temporaryMode, - temporaryAccessEndTime, - temporaryAccessStartTime, - isTemporary - }) => ({ - id: membershipRoleId, - role, - customRoleId, - customRoleName, - customRoleSlug, - temporaryRange, - temporaryMode, - temporaryAccessEndTime, - temporaryAccessStartTime, - isTemporary - }) - } - ] - } - ] - }); - - return groupsWithProjects; + return docs; } catch (error) { throw new DatabaseError({ error, name: "FindByUserId" }); } diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index 97bbaecf0..8d7d1ffbe 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -1,4 +1,8 @@ +import { ForbiddenError } from "@casl/ability"; + import { SecretKeyEncoding } from "@app/db/schemas"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; import { BadRequestError } from "@app/lib/errors"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; @@ -11,6 +15,7 @@ import { AuthMethod } from "../auth/auth-type"; import { TGroupProjectDALFactory } from "../group-project/group-project-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TUserDALFactory } from "./user-dal"; +import { TListUserGroupsDTO } from "./user-types"; type TUserServiceFactoryDep = { userDAL: Pick< @@ -33,6 +38,7 @@ type TUserServiceFactoryDep = { tokenService: Pick; projectMembershipDAL: Pick; smtpService: Pick; + permissionService: TPermissionServiceFactory; }; export type TUserServiceFactory = ReturnType; @@ -44,7 +50,8 @@ export const userServiceFactory = ({ projectMembershipDAL, groupProjectDAL, tokenService, - smtpService + smtpService, + permissionService }: TUserServiceFactoryDep) => { const sendEmailVerificationCode = async (username: string) => { const user = await userDAL.findOne({ username }); @@ -298,13 +305,24 @@ export const userServiceFactory = ({ return updatedOrgMembership.projectFavorites; }; - const listUserGroups = async (username: string, orgId: string) => { + const listUserGroups = async ({ username, actorOrgId, actor, actorId, actorAuthMethod }: TListUserGroupsDTO) => { const user = await userDAL.findOne({ username }); - const memberships = await groupProjectDAL.findByUserId(user.id, orgId); + // This makes it so the user can always read information about themselves, but no one else if they don't have the Members Read permission. + if (user.id !== actorId) { + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); + } + const memberships = await groupProjectDAL.findByUserId(user.id, actorOrgId); return memberships; }; diff --git a/backend/src/services/user/user-types.ts b/backend/src/services/user/user-types.ts index e69de29bb..e91b23910 100644 --- a/backend/src/services/user/user-types.ts +++ b/backend/src/services/user/user-types.ts @@ -0,0 +1,5 @@ +import { TOrgPermission } from "@app/lib/types"; + +export type TListUserGroupsDTO = { + username: string; +} & Omit; diff --git a/frontend/src/hooks/api/groups/types.ts b/frontend/src/hooks/api/groups/types.ts index 126ca56d4..3f69b9a0e 100644 --- a/frontend/src/hooks/api/groups/types.ts +++ b/frontend/src/hooks/api/groups/types.ts @@ -40,24 +40,4 @@ export type TGroupWithProjectMemberships = { name: string; slug: string; orgId: string; - projectMemberships: { - id: string; - project: { - id: string; - name: string; - slug: string; - }; - roles: { - id: string; - role: string; - customRoleId: string | null; - customRoleName: string | null; - customRoleSlug: string | null; - temporaryRange: string | null; - temporaryMode: string | null; - temporaryAccessEndTime: string | null; - temporaryAccessStartTime: string | null; - isTemporary: boolean; - }[]; - }[]; }; diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsRow.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsRow.tsx index 712f8f0be..620fe5b7e 100644 --- a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsRow.tsx +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsRow.tsx @@ -20,26 +20,24 @@ export const UserGroupsRow = ({ group, handlePopUpOpen }: Props) => { > {group.name} - {true && ( -
- - { - e.stopPropagation(); - handlePopUpOpen("removeUserFromGroup", { - groupSlug: group.slug - }); - }} - > - - - -
- )} +
+ + { + e.stopPropagation(); + handlePopUpOpen("removeUserFromGroup", { + groupSlug: group.slug + }); + }} + > + + + +
diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsSection.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsSection.tsx index 41340aec4..2967348a2 100644 --- a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsSection.tsx +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsSection.tsx @@ -52,7 +52,7 @@ export const UserGroupsSection = ({ orgMembership }: Props) => { handlePopUpToggle("removeUserFromGroup", isOpen)} deleteKey="confirm" onDeleteApproved={() => { From f03ca7f9169324bb518646be0ca18719ba6e6067 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Wed, 11 Sep 2024 10:30:16 -0700 Subject: [PATCH 17/17] Minor adjustments --- backend/src/ee/services/audit-log/audit-log-service.ts | 4 ++++ frontend/src/views/Org/UserPage/UserPage.tsx | 2 +- .../components/UserProjectsSection/UserAuditLogsSection.tsx | 4 ++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/backend/src/ee/services/audit-log/audit-log-service.ts b/backend/src/ee/services/audit-log/audit-log-service.ts index b730f5ee7..11159c37b 100644 --- a/backend/src/ee/services/audit-log/audit-log-service.ts +++ b/backend/src/ee/services/audit-log/audit-log-service.ts @@ -55,6 +55,10 @@ export const auditLogServiceFactory = ({ actorOrgId ); + /** + * NOTE (dangtony98): Update this to organization-level audit log permission check once audit logs are moved + * to the organization level + */ ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); } diff --git a/frontend/src/views/Org/UserPage/UserPage.tsx b/frontend/src/views/Org/UserPage/UserPage.tsx index 5c10e1977..ad0f66d6e 100644 --- a/frontend/src/views/Org/UserPage/UserPage.tsx +++ b/frontend/src/views/Org/UserPage/UserPage.tsx @@ -244,7 +244,7 @@ export const UserPage = withPermission(
-
+
diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAuditLogsSection.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAuditLogsSection.tsx index 1736e8333..dcfd0553c 100644 --- a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAuditLogsSection.tsx +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAuditLogsSection.tsx @@ -47,7 +47,7 @@ export const UserAuditLogsSection = withPermission( />
) : !isLoading ? ( -
+

Audit Logs

@@ -72,7 +72,7 @@ export const UserAuditLogsSection = withPermission( upgrade your subscription {" "} - to view audit logs. + to view audit logs for this user

}