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..11159c37b 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; }; @@ -22,7 +23,7 @@ export const auditLogServiceFactory = ({ auditLogQueue, permissionService }: TAuditLogServiceFactoryDep) => { - const listProjectAuditLogs = async ({ + const listAuditLogs = async ({ userAgentType, eventType, offset, @@ -36,14 +37,33 @@ 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); + } else { + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + 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); + } + + // 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 +72,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 +97,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 10f97a163..3b3a5b107 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; eventType?: string; startDate?: string; endDate?: string; userAgentType?: string; limit?: number; offset?: number; -} & TProjectPermission; +} & Omit; export type TCreateAuditLogDTO = { event: Event; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 8f6224c51..af0aa3d8a 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -473,6 +473,8 @@ 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 808f125bb..68a1dba45 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,68 @@ 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 + }); + return { auditLogs }; + } + }); + server.route({ method: "GET", url: "/:organizationId/users", diff --git a/backend/src/server/routes/v1/user-router.ts b/backend/src/server/routes/v1/user-router.ts index d3c0db242..4e4583196 100644 --- a/backend/src/server/routes/v1/user-router.ts +++ b/backend/src/server/routes/v1/user-router.ts @@ -134,4 +134,39 @@ 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() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + 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 fedfcfbc3..c74a6b1b2 100644 --- a/backend/src/services/group-project/group-project-dal.ts +++ b/backend/src/services/group-project/group-project-dal.ts @@ -95,6 +95,30 @@ 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]) + ); + }) + .select( + db.ref("id").withSchema(TableName.Groups), + db.ref("name").withSchema(TableName.Groups), + db.ref("slug").withSchema(TableName.Groups), + db.ref("orgId").withSchema(TableName.Groups) + ); + + return docs; + } 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 +221,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..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"; @@ -8,8 +12,10 @@ 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"; +import { TListUserGroupsDTO } from "./user-types"; type TUserServiceFactoryDep = { userDAL: Pick< @@ -27,10 +33,12 @@ type TUserServiceFactoryDep = { | "delete" >; userAliasDAL: Pick; + groupProjectDAL: Pick; orgMembershipDAL: Pick; tokenService: Pick; projectMembershipDAL: Pick; smtpService: Pick; + permissionService: TPermissionServiceFactory; }; export type TUserServiceFactory = ReturnType; @@ -40,8 +48,10 @@ export const userServiceFactory = ({ userAliasDAL, orgMembershipDAL, projectMembershipDAL, + groupProjectDAL, tokenService, - smtpService + smtpService, + permissionService }: TUserServiceFactoryDep) => { const sendEmailVerificationCode = async (username: string) => { const user = await userDAL.findOne({ username }); @@ -295,6 +305,27 @@ export const userServiceFactory = ({ return updatedOrgMembership.projectFavorites; }; + const listUserGroups = async ({ username, actorOrgId, actor, actorId, actorAuthMethod }: TListUserGroupsDTO) => { + const user = await userDAL.findOne({ + username + }); + + // 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; + }; + return { sendEmailVerificationCode, verifyEmailVerificationCode, @@ -304,6 +335,7 @@ export const userServiceFactory = ({ deleteUser, getMe, createUserAction, + listUserGroups, getUserAction, unlockUser, getUserPrivateKey, diff --git a/backend/src/services/user/user-types.ts b/backend/src/services/user/user-types.ts index f7b46978a..9b482de98 100644 --- a/backend/src/services/user/user-types.ts +++ b/backend/src/services/user/user-types.ts @@ -1,3 +1,9 @@ +import { TOrgPermission } from "@app/lib/types"; + +export type TListUserGroupsDTO = { + username: string; +} & Omit; + export enum UserEncryption { V1 = 1, V2 = 2 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 85f4c2f73..890327e0e 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -851,6 +851,10 @@ export type AuditLog = { userAgentType: UserAgentType; createdAt: string; updatedAt: string; + project: { + name: string; + slug: string; + }; }; export type AuditLogFilters = { 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)); + } + }); +}; diff --git a/frontend/src/hooks/api/groups/types.ts b/frontend/src/hooks/api/groups/types.ts index 3db2357cd..3f69b9a0e 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,11 @@ export type TGroupMembership = { }[]; createdAt: string; updatedAt: string; -}; \ No newline at end of file +}; + +export type TGroupWithProjectMemberships = { + id: string; + name: string; + slug: string; + orgId: string; +}; 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; + } + }); +}; 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)); + } } }); }; diff --git a/frontend/src/views/Org/UserPage/UserPage.tsx b/frontend/src/views/Org/UserPage/UserPage.tsx index f1ac3fae8..ad0f66d6e 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)} 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..dcfd0553c --- /dev/null +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAuditLogsSection.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import Link from "next/link"; +import { faFilter } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +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"; + +type Props = { + orgMembership: OrgUser; +}; + +export const UserAuditLogsSection = withPermission( + ({ orgMembership }: Props) => { + const [showFilter, setShowFilter] = useState(false); + const { subscription, isLoading } = useSubscription(); + + // eslint-disable-next-line no-nested-ternary + return subscription?.auditLogs ? ( +
+
+

Audit Logs

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

Filter

+ +
+
+
+
+ +
+ ) : !isLoading ? ( +
+
+

Audit Logs

+
+ +

+ Please{" "} + + + upgrade your subscription + + {" "} + to view audit logs for this user +

+
+ } + /> + + ) : null; + }, + { action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Member } +); diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsRow.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsRow.tsx new file mode 100644 index 000000000..620fe5b7e --- /dev/null +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsRow.tsx @@ -0,0 +1,45 @@ +/* eslint-disable react/jsx-no-useless-fragment */ +import { faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { IconButton, Td, Tooltip, Tr } from "@app/components/v2"; +import { TGroupWithProjectMemberships } from "@app/hooks/api/groups/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + group: TGroupWithProjectMemberships; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromGroup"]>, data?: {}) => void; +}; + +export const UserGroupsRow = ({ group, handlePopUpOpen }: Props) => { + return ( + <> + + {group.name} + +
+ + { + 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..2967348a2 --- /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..15299da26 --- /dev/null +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserGroupsTable.tsx @@ -0,0 +1,42 @@ +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) => ( + + ))} + +
Name +
+ {!isLoading && !groups?.length && ( + + )} +
+ ); +}; 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/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" >