diff --git a/backend/src/db/migrations/20250313124706_add-privilege-upgrade-field.ts b/backend/src/db/migrations/20250313124706_add-privilege-upgrade-field.ts index 9823f4d8e..5a16666af 100644 --- a/backend/src/db/migrations/20250313124706_add-privilege-upgrade-field.ts +++ b/backend/src/db/migrations/20250313124706_add-privilege-upgrade-field.ts @@ -1,4 +1,5 @@ import { Knex } from "knex"; + import { TableName } from "../schemas"; export async function up(knex: Knex): Promise { diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index e626443c3..f58cbe400 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -233,3 +233,8 @@ export enum ActionProjectType { // project operations that happen on all types Any = "any" } + +export enum SortDirection { + ASC = "asc", + DESC = "desc" +} 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 5e7df9444..509d6754e 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -285,7 +285,9 @@ export enum EventType { KMIP_OPERATION_ACTIVATE = "kmip-operation-activate", KMIP_OPERATION_REVOKE = "kmip-operation-revoke", KMIP_OPERATION_LOCATE = "kmip-operation-locate", - KMIP_OPERATION_REGISTER = "kmip-operation-register" + KMIP_OPERATION_REGISTER = "kmip-operation-register", + + PROJECT_ACCESS_REQUEST = "project-access-request" } export const filterableSecretEvents: EventType[] = [ @@ -2277,6 +2279,15 @@ interface KmipOperationRegisterEvent { }; } +interface ProjectAccessRequestEvent { + type: EventType.PROJECT_ACCESS_REQUEST; + metadata: { + projectId: string; + requesterId: string; + requesterEmail: string; + }; +} + interface SetupKmipEvent { type: EventType.SETUP_KMIP; metadata: { @@ -2511,5 +2522,6 @@ export type Event = | KmipOperationRevokeEvent | KmipOperationLocateEvent | KmipOperationRegisterEvent + | ProjectAccessRequestEvent | CreateSecretRequestEvent | SecretApprovalRequestReview; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index bd6056359..ca59c1fc8 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -633,7 +633,8 @@ export const FOLDERS = { path: "The path to list folders from.", directory: "The directory to list folders from. (Deprecated in favor of path)", recursive: "Whether or not to fetch all folders from the specified base path, and all of its subdirectories.", - lastSecretModified: "The timestamp used to filter folders with secrets modified after the specified date. The format for this timestamp is ISO 8601 (e.g. 2025-04-01T09:41:45-04:00)" + lastSecretModified: + "The timestamp used to filter folders with secrets modified after the specified date. The format for this timestamp is ISO 8601 (e.g. 2025-04-01T09:41:45-04:00)" }, GET_BY_ID: { folderId: "The ID of the folder to get details." diff --git a/backend/src/lib/validator/validate-string.ts b/backend/src/lib/validator/validate-string.ts index 57bc052f8..bc279fdbf 100644 --- a/backend/src/lib/validator/validate-string.ts +++ b/backend/src/lib/validator/validate-string.ts @@ -36,7 +36,8 @@ export enum CharacterType { DoubleQuote = "doubleQuote", // " Comma = "comma", // , Semicolon = "semicolon", // ; - Exclamation = "exclamation" // ! + Exclamation = "exclamation", // ! + Fullstop = "fullStop" // . } /** @@ -81,7 +82,8 @@ export const characterValidator = (allowedCharacters: CharacterType[]) => { [CharacterType.DoubleQuote]: '\\"', [CharacterType.Comma]: ",", [CharacterType.Semicolon]: ";", - [CharacterType.Exclamation]: "!" + [CharacterType.Exclamation]: "!", + [CharacterType.Fullstop]: "." }; // Combine patterns from allowed characters diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index dac88791f..977a3b921 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -662,6 +662,7 @@ export const registerRoutes = async ( }); const orgAdminService = orgAdminServiceFactory({ + smtpService, projectDAL, permissionService, projectUserMembershipRoleDAL, @@ -964,7 +965,8 @@ export const registerRoutes = async ( projectSlackConfigDAL, slackIntegrationDAL, projectTemplateService, - groupProjectDAL + groupProjectDAL, + smtpService }); const projectEnvService = projectEnvServiceFactory({ diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index b44e93a66..030d9c91c 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -8,15 +8,17 @@ import { ProjectSlackConfigsSchema, ProjectType, SecretFoldersSchema, + SortDirection, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { PROJECTS } from "@app/lib/api-docs"; +import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; 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"; -import { ProjectFilterType } from "@app/services/project/project-types"; +import { ActorType, AuthMode } from "@app/services/auth/auth-type"; +import { ProjectFilterType, SearchProjectSortBy } from "@app/services/project/project-types"; import { validateSlackChannelsField } from "@app/services/slack/slack-auth-validators"; import { integrationAuthPubSchema, SanitizedProjectSchema } from "../sanitizedSchemas"; @@ -704,4 +706,106 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { return environmentsFolders; } }); + + server.route({ + method: "POST", + url: "/search", + config: { + rateLimit: readLimit + }, + schema: { + body: z.object({ + limit: z.number().default(100), + offset: z.number().default(0), + type: z.nativeEnum(ProjectType).optional(), + orderBy: z.nativeEnum(SearchProjectSortBy).optional().default(SearchProjectSortBy.NAME), + orderDirection: z.nativeEnum(SortDirection).optional().default(SortDirection.ASC), + name: z + .string() + .trim() + .refine((val) => characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen])(val), { + message: "Invalid pattern: only alphanumeric characters, - are allowed." + }) + .optional() + }), + response: { + 200: z.object({ + projects: SanitizedProjectSchema.extend({ isMember: z.boolean() }).array(), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { docs: projects, totalCount } = await server.services.project.searchProjects({ + permission: req.permission, + ...req.body + }); + + return { projects, totalCount }; + } + }); + + server.route({ + method: "POST", + url: "/:workspaceId/project-access", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + comment: z + .string() + .trim() + .refine( + (val) => + characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.Hyphen, + CharacterType.Comma, + CharacterType.Fullstop, + CharacterType.Spaces, + CharacterType.Exclamation + ])(val), + { + message: "Invalid pattern: only alphanumeric characters, spaces, -.!, are allowed." + } + ) + .optional() + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + await server.services.project.requestProjectAccess({ + permission: req.permission, + comment: req.body.comment, + projectId: req.params.workspaceId + }); + + if (req.auth.actor === ActorType.USER) { + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.params.workspaceId, + event: { + type: EventType.PROJECT_ACCESS_REQUEST, + metadata: { + projectId: req.params.workspaceId, + requesterEmail: req.auth.user.email || req.auth.user.username, + requesterId: req.auth.userId + } + } + }); + } + + return { message: "Project access request has been send to project admins" }; + } + }); }; diff --git a/backend/src/services/org-admin/org-admin-service.ts b/backend/src/services/org-admin/org-admin-service.ts index c9f792978..15ad09e46 100644 --- a/backend/src/services/org-admin/org-admin-service.ts +++ b/backend/src/services/org-admin/org-admin-service.ts @@ -12,17 +12,22 @@ import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; +import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TAccessProjectDTO, TListOrgProjectsDTO } from "./org-admin-types"; type TOrgAdminServiceFactoryDep = { permissionService: Pick; projectDAL: Pick; - projectMembershipDAL: Pick; + projectMembershipDAL: Pick< + TProjectMembershipDALFactory, + "findOne" | "create" | "transaction" | "delete" | "findAllProjectMembers" + >; projectKeyDAL: Pick; projectBotDAL: Pick; userDAL: Pick; projectUserMembershipRoleDAL: Pick; + smtpService: Pick; }; export type TOrgAdminServiceFactory = ReturnType; @@ -34,7 +39,8 @@ export const orgAdminServiceFactory = ({ projectKeyDAL, projectBotDAL, userDAL, - projectUserMembershipRoleDAL + projectUserMembershipRoleDAL, + smtpService }: TOrgAdminServiceFactoryDep) => { const listOrgProjects = async ({ actor, @@ -184,6 +190,23 @@ export const orgAdminServiceFactory = ({ ); return newProjectMembership; }); + + const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId); + const filteredProjectMembers = projectMembers + .filter( + (member) => member.roles.some((role) => role.role === ProjectMembershipRole.Admin) && member.userId !== actorId + ) + .map((el) => el.user.email!); + + await smtpService.sendMail({ + template: SmtpTemplates.OrgAdminProjectDirectAccess, + recipients: filteredProjectMembers, + subjectLine: "Organization Admin Project Direct Access Issued", + substitutions: { + projectName: project.name, + email: projectMembers.find((el) => el.userId === actorId)?.user?.username + } + }); return { isExistingMember: false, membership: updatedMembership }; }; diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 1868871f3..98b35f68f 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -231,7 +231,7 @@ export const orgServiceFactory = ({ const findAllWorkspaces = async ({ actor, actorId, orgId, type }: TFindAllWorkspacesDTO) => { if (actor === ActorType.USER) { - const workspaces = await projectDAL.findAllProjects(actorId, orgId, type || "all"); + const workspaces = await projectDAL.findUserProjects(actorId, orgId, type || "all"); return workspaces; } diff --git a/backend/src/services/project/project-dal.ts b/backend/src/services/project/project-dal.ts index 896e1b858..43f1d57e4 100644 --- a/backend/src/services/project/project-dal.ts +++ b/backend/src/services/project/project-dal.ts @@ -6,20 +6,23 @@ import { ProjectType, ProjectUpgradeStatus, ProjectVersion, + SortDirection, TableName, + TProjects, TProjectsUpdate } from "@app/db/schemas"; import { BadRequestError, DatabaseError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; -import { Filter, ProjectFilterType } from "./project-types"; +import { ActorType } from "../auth/auth-type"; +import { Filter, ProjectFilterType, SearchProjectSortBy } from "./project-types"; export type TProjectDALFactory = ReturnType; export const projectDALFactory = (db: TDbClient) => { const projectOrm = ormify(db, TableName.Project); - const findAllProjects = async (userId: string, orgId: string, projectType: ProjectType | "all") => { + const findUserProjects = async (userId: string, orgId: string, projectType: ProjectType | "all") => { try { const workspaces = await db .replicaNode()(TableName.ProjectMembership) @@ -352,9 +355,79 @@ export const projectDALFactory = (db: TDbClient) => { } }; + const searchProjects = async (dto: { + orgId: string; + actor: ActorType; + actorId: string; + type?: ProjectType; + limit?: number; + offset?: number; + name?: string; + sortBy?: SearchProjectSortBy; + sortDir?: SortDirection; + }) => { + const { limit = 20, offset = 0, sortBy = SearchProjectSortBy.NAME, sortDir = SortDirection.ASC } = dto; + + const userMembershipSubquery = db(TableName.ProjectMembership).where({ userId: dto.actorId }).select("projectId"); + const groups = db(TableName.UserGroupMembership).where({ userId: dto.actorId }).select("groupId"); + const groupMembershipSubquery = db(TableName.GroupProjectMembership).whereIn("groupId", groups).select("projectId"); + + const identityMembershipSubQuery = db(TableName.IdentityProjectMembership) + .where({ identityId: dto.actorId }) + .select("projectId"); + + // Get the SQL strings for the subqueries + const userMembershipSql = userMembershipSubquery.toQuery(); + const groupMembershipSql = groupMembershipSubquery.toQuery(); + const identityMembershipSql = identityMembershipSubQuery.toQuery(); + + const query = db + .replicaNode()(TableName.Project) + .where(`${TableName.Project}.orgId`, dto.orgId) + .select(selectAllTableCols(TableName.Project)) + .select(db.raw("COUNT(*) OVER() AS count")) + .select<(TProjects & { isMember: boolean; count: number })[]>( + dto.actor === ActorType.USER + ? db.raw( + ` + CASE + WHEN ${TableName.Project}.id IN (?) THEN TRUE + WHEN ${TableName.Project}.id IN (?) THEN TRUE + ELSE FALSE + END as "isMember" + `, + [db.raw(userMembershipSql), db.raw(groupMembershipSql)] + ) + : db.raw( + ` + CASE + WHEN ${TableName.Project}.id IN (?) THEN TRUE + ELSE FALSE + END as "isMember" + `, + [db.raw(identityMembershipSql)] + ) + ) + .limit(limit) + .offset(offset); + if (sortBy === SearchProjectSortBy.NAME) { + void query.orderBy([{ column: `${TableName.Project}.name`, order: sortDir }]); + } + + if (dto.type) { + void query.where(`${TableName.Project}.type`, dto.type); + } + if (dto.name) { + void query.whereILike(`${TableName.Project}.name`, `%${dto.name}%`); + } + const docs = await query; + + return { docs, totalCount: Number(docs?.[0]?.count ?? 0) }; + }; + return { ...projectOrm, - findAllProjects, + findUserProjects, setProjectUpgradeStatus, findAllProjectsByIdentity, findProjectGhostUser, @@ -363,6 +436,7 @@ export const projectDALFactory = (db: TDbClient) => { findProjectBySlug, findProjectWithOrg, checkProjectUpgradeStatus, - getProjectFromSplitId + getProjectFromSplitId, + searchProjects }; }; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index dbef33c10..58e3f9b54 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -23,6 +23,7 @@ import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-cer import { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal"; import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; import { TKeyStoreFactory } from "@app/keystore/keystore"; +import { getConfig } from "@app/lib/config/env"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; @@ -57,6 +58,7 @@ import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secr import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; import { TProjectSlackConfigDALFactory } from "../slack/project-slack-config-dal"; import { TSlackIntegrationDALFactory } from "../slack/slack-integration-dal"; +import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TProjectDALFactory } from "./project-dal"; import { assignWorkspaceKeysToMembers, createProjectKey } from "./project-fns"; @@ -76,6 +78,8 @@ import { TListProjectSshCertificatesDTO, TListProjectSshCertificateTemplatesDTO, TLoadProjectKmsBackupDTO, + TProjectAccessRequestDTO, + TSearchProjectsDTO, TToggleProjectAutoCapitalizationDTO, TUpdateAuditLogsRetentionDTO, TUpdateProjectDTO, @@ -106,7 +110,10 @@ type TProjectServiceFactoryDep = { identityProjectDAL: TIdentityProjectDALFactory; identityProjectMembershipRoleDAL: Pick; projectKeyDAL: Pick; - projectMembershipDAL: Pick; + projectMembershipDAL: Pick< + TProjectMembershipDALFactory, + "create" | "findProjectGhostUser" | "findOne" | "delete" | "findAllProjectMembers" + >; groupProjectDAL: Pick; projectSlackConfigDAL: Pick; slackIntegrationDAL: Pick; @@ -123,6 +130,7 @@ type TProjectServiceFactoryDep = { orgService: Pick; licenseService: Pick; queueService: Pick; + smtpService: Pick; orgDAL: Pick; keyStore: Pick; @@ -177,7 +185,8 @@ export const projectServiceFactory = ({ projectSlackConfigDAL, slackIntegrationDAL, projectTemplateService, - groupProjectDAL + groupProjectDAL, + smtpService }: TProjectServiceFactoryDep) => { /* * Create workspace. Make user the admin @@ -506,7 +515,7 @@ export const projectServiceFactory = ({ actorOrgId, type = ProjectType.SecretManager }: TListProjectsDTO) => { - const workspaces = await projectDAL.findAllProjects(actorId, actorOrgId, type); + const workspaces = await projectDAL.findUserProjects(actorId, actorOrgId, type); if (includeRoles) { const { permission } = await permissionService.getUserOrgPermission( @@ -1339,6 +1348,85 @@ export const projectServiceFactory = ({ }); }; + const searchProjects = async ({ + name, + offset, + permission, + limit, + type, + orderBy, + orderDirection + }: TSearchProjectsDTO) => { + // check user belong to org + await permissionService.getOrgPermission( + permission.type, + permission.id, + permission.orgId, + permission.authMethod, + permission.orgId + ); + + return projectDAL.searchProjects({ + limit, + offset, + name, + type, + orgId: permission.orgId, + actor: permission.type, + actorId: permission.id, + sortBy: orderBy, + sortDir: orderDirection + }); + }; + + const requestProjectAccess = async ({ permission, comment, projectId }: TProjectAccessRequestDTO) => { + // check user belong to org + await permissionService.getOrgPermission( + permission.type, + permission.id, + permission.orgId, + permission.authMethod, + permission.orgId + ); + + const projectMember = await permissionService + .getProjectPermission({ + actor: permission.type, + actorId: permission.id, + projectId, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: permission.authMethod, + actorOrgId: permission.orgId + }) + .catch(() => { + return null; + }); + if (projectMember) throw new BadRequestError({ message: "User already has access to the project" }); + + const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId); + const filteredProjectMembers = projectMembers + .filter((member) => member.roles.some((role) => role.role === ProjectMembershipRole.Admin)) + .map((el) => el.user.email!); + const org = await orgDAL.findOne({ id: permission.orgId }); + const project = await projectDAL.findById(projectId); + const userDetails = await userDAL.findById(permission.id); + const appCfg = getConfig(); + + await smtpService.sendMail({ + template: SmtpTemplates.ProjectAccessRequest, + recipients: filteredProjectMembers, + subjectLine: "Project Access Request", + substitutions: { + requesterName: `${userDetails.firstName} ${userDetails.lastName}`, + requesterEmail: userDetails.email, + projectName: project?.name, + orgName: org?.name, + note: comment, + callback_url: `${appCfg.SITE_URL}/${project.type}/${project.id}/access-management?selectedTab=members&requesterEmail=${userDetails.email}` + } + }); + }; + return { createProject, deleteProject, @@ -1364,6 +1452,8 @@ export const projectServiceFactory = ({ loadProjectKmsBackup, getProjectKmsKeys, getProjectSlackConfig, - updateProjectSlackConfig + updateProjectSlackConfig, + requestProjectAccess, + searchProjects }; }; diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 5ccf33d23..30519005d 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -1,7 +1,7 @@ import { Knex } from "knex"; -import { ProjectType, TProjectKeys } from "@app/db/schemas"; -import { TProjectPermission } from "@app/lib/types"; +import { ProjectType, SortDirection, TProjectKeys } from "@app/db/schemas"; +import { OrgServiceActor, TProjectPermission } from "@app/lib/types"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; @@ -158,3 +158,23 @@ export type TUpdateProjectSlackConfig = { isSecretRequestNotificationEnabled: boolean; secretRequestChannels: string; } & TProjectPermission; + +export enum SearchProjectSortBy { + NAME = "name" +} + +export type TSearchProjectsDTO = { + permission: OrgServiceActor; + name?: string; + type?: ProjectType; + limit?: number; + offset?: number; + orderBy?: SearchProjectSortBy; + orderDirection?: SortDirection; +}; + +export type TProjectAccessRequestDTO = { + permission: OrgServiceActor; + projectId: string; + comment?: string; +}; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 68e0ecd22..81416eae8 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -40,7 +40,9 @@ export enum SmtpTemplates { ExternalImportSuccessful = "externalImportSuccessful.handlebars", ExternalImportFailed = "externalImportFailed.handlebars", ExternalImportStarted = "externalImportStarted.handlebars", - SecretRequestCompleted = "secretRequestCompleted.handlebars" + SecretRequestCompleted = "secretRequestCompleted.handlebars", + ProjectAccessRequest = "projectAccess.handlebars", + OrgAdminProjectDirectAccess = "orgAdminProjectGrantAccess.handlebars" } export enum SmtpHost { diff --git a/backend/src/services/smtp/templates/accessApprovalRequest.handlebars b/backend/src/services/smtp/templates/accessApprovalRequest.handlebars index 3c0811a1c..ef11957a7 100644 --- a/backend/src/services/smtp/templates/accessApprovalRequest.handlebars +++ b/backend/src/services/smtp/templates/accessApprovalRequest.handlebars @@ -49,4 +49,4 @@ {{emailFooter}} - \ No newline at end of file + diff --git a/backend/src/services/smtp/templates/orgAdminProjectGrantAccess.handlebars b/backend/src/services/smtp/templates/orgAdminProjectGrantAccess.handlebars new file mode 100644 index 000000000..ef8c6e6b4 --- /dev/null +++ b/backend/src/services/smtp/templates/orgAdminProjectGrantAccess.handlebars @@ -0,0 +1,16 @@ + + + + + + Organization admin issued direct access to project + + + +

Infisical

+

The organization admin {{email}} has granted direct access to the project "{{projectName}}".

+ + {{emailFooter}} + + + diff --git a/backend/src/services/smtp/templates/projectAccess.handlebars b/backend/src/services/smtp/templates/projectAccess.handlebars new file mode 100644 index 000000000..5ff1ca7ec --- /dev/null +++ b/backend/src/services/smtp/templates/projectAccess.handlebars @@ -0,0 +1,26 @@ + + + + + + Project Access Request + + + +

Infisical

+

You have a new project access request!

+
    +
  • Requester Name: "{{requesterName}}"
  • +
  • Requester Email: "{{requesterEmail}}"
  • +
  • Project Name: "{{projectName}}"
  • +
  • Organization Name: "{{orgName}}"
  • +
  • User Note: "{{note}}"
  • +
+

+ Please click on the link below to grant access +

+ Grant Access + {{emailFooter}} + + + diff --git a/docs/documentation/platform/access-controls/project-access-requests.mdx b/docs/documentation/platform/access-controls/project-access-requests.mdx new file mode 100644 index 000000000..38812c003 --- /dev/null +++ b/docs/documentation/platform/access-controls/project-access-requests.mdx @@ -0,0 +1,36 @@ +--- +title: "Project Access Requests" +description: "Learn how to request access to projects in Infisical." +--- + +The Project Access Request feature allows users to view all projects within organization, including those they don't currently have access to. +Users can request access to these projects by submitting a request that automatically notifies project administrators via email, along with any comments provided by the user. + +# Viewing Available Projects + +From the Infisical dashboard, users can view all projects within the organization: + +1. Navigate to the main dashboard after logging in +2. The overview page for each product displays two tabs: + + - **My Projects**: Projects you currently have access to + - **All Projects**: Complete list of projects in the organization + +![all-project-view](/images/platform/project-access-requests/all-project-view.png) + +# Requesting Access to a Project + +To request access to a project you don't currently have access for: + +1. Click the **Request Access** button next to the project name + ![all-project-view](/images/platform/project-access-requests/request-access.png) + +2. Add a comment explaining why you need access + ![all-project-view](/images/platform/project-access-requests/access-comment.png) + +3. Click **Submit Request** + + + Project administrators will receive email notification with details regarding + the access request. + diff --git a/docs/documentation/platform/admin-panel/org-admin-console.mdx b/docs/documentation/platform/admin-panel/org-admin-console.mdx index 39d7819a4..08a327268 100644 --- a/docs/documentation/platform/admin-panel/org-admin-console.mdx +++ b/docs/documentation/platform/admin-panel/org-admin-console.mdx @@ -4,13 +4,13 @@ description: "View and manage resources across your organization" --- - The Organization Admin Console can only be accessed by organization members with admin status. + The Organization Admin Console can only be accessed by organization members + with admin status. - ## Accessing the Organization Admin Console -On the sidebar, tap on your initials to access the settings dropdown and press the **Organization Admin Console** option. +On the sidebar, hover over **Admin** to access the settings dropdown and press the **Organization Admin Console** option. ![Access Organization Admin Console](/images/platform/admin-panels/access-org-admin-console.png) @@ -20,12 +20,9 @@ The Projects tab lists all the projects within your organization, including thos ![Projects Section](/images/platform/admin-panels/org-admin-console-projects.png) - ### Accessing a Project in Your Organization You can access a project that you are not a member of by tapping on the options menu of the project row and pressing the **Access** button. Doing so will grant you admin permissions for the selected project and add you as a member. ![Access project](/images/platform/admin-panels/org-admin-console-access.png) - - diff --git a/docs/documentation/platform/admin-panel/server-admin.mdx b/docs/documentation/platform/admin-panel/server-admin.mdx index aa604e2a5..198e5c37c 100644 --- a/docs/documentation/platform/admin-panel/server-admin.mdx +++ b/docs/documentation/platform/admin-panel/server-admin.mdx @@ -13,7 +13,7 @@ customize settings and manage users for their entire Infisical instance. ## Accessing the Server Admin Console -On the sidebar, tap on your initials to access the settings dropdown and press the **Server Admin Console** option. +On the sidebar, hover over **Admin** to access the settings dropdown and press the **Server Admin Console** option. ![Access Server Admin Console](/images/platform/admin-panels/access-server-admin-panel.png) diff --git a/docs/documentation/platform/project.mdx b/docs/documentation/platform/project.mdx index bd80d8ae5..dcf447169 100644 --- a/docs/documentation/platform/project.mdx +++ b/docs/documentation/platform/project.mdx @@ -3,19 +3,21 @@ title: "Projects" description: "Learn more and understand the concept of Infisical projects." --- -A project in Infisical belongs to an [organization](./organization) and contains a number of environments, folders, and secrets. -Only users and machine identities who belong to a project can access resources inside of it according to predefined permissions. +A project in Infisical belongs to an [organization](./organization) and contains a number of environments, folders, and secrets. +Only users and machine identities who belong to a project can access resources inside of it according to predefined permissions. + +Infisical also allows users to request project access. Refer to the [project access request section](./access-controls/project-access-requests) ## Project environments -For both visual and organizational structure, Infisical allows splitting up secrets into environments (e.g., development, staging, production). In project settings, such environments can be -customized depending on the intended use case. +For both visual and organizational structure, Infisical allows splitting up secrets into environments (e.g., development, staging, production). In project settings, such environments can be +customized depending on the intended use case. ![project secrets overview](../../images/platform/project/project-environments.png) ## Secrets Overview -The **Secrets Overview** page captures a birds-eye-view of secrets and [folders](./folder) across environments. +The **Secrets Overview** page captures a birds-eye-view of secrets and [folders](./folder) across environments. This is useful for comparing secrets, identifying if anything is missing, and making quick changes. ![project secrets overview](../../images/platform/project/project-secrets-overview-open.png) @@ -98,7 +100,7 @@ Then: - If users B and C fetch the secret D back, they both get the value E. - Please keep in mind that secret reminders won't work with personal overrides. + Please keep in mind that secret reminders won't work with personal overrides. ![project override secret](../../images/platform/project/project-secrets-override.png) @@ -112,4 +114,3 @@ To view the full details of each secret, you can hover over it and press on the This opens up a side-drawer: ![project secrets drawer](../../images/platform/project/project-secrets-drawer.png) - diff --git a/docs/images/platform/admin-panels/access-org-admin-console.png b/docs/images/platform/admin-panels/access-org-admin-console.png index 057c82944..8d291d449 100644 Binary files a/docs/images/platform/admin-panels/access-org-admin-console.png and b/docs/images/platform/admin-panels/access-org-admin-console.png differ diff --git a/docs/images/platform/admin-panels/access-server-admin-panel.png b/docs/images/platform/admin-panels/access-server-admin-panel.png index a27735de0..706ba0814 100644 Binary files a/docs/images/platform/admin-panels/access-server-admin-panel.png and b/docs/images/platform/admin-panels/access-server-admin-panel.png differ diff --git a/docs/images/platform/project-access-requests/access-comment.png b/docs/images/platform/project-access-requests/access-comment.png new file mode 100644 index 000000000..a4bf45d6c Binary files /dev/null and b/docs/images/platform/project-access-requests/access-comment.png differ diff --git a/docs/images/platform/project-access-requests/all-project-view.png b/docs/images/platform/project-access-requests/all-project-view.png new file mode 100644 index 000000000..71fb4199d Binary files /dev/null and b/docs/images/platform/project-access-requests/all-project-view.png differ diff --git a/docs/images/platform/project-access-requests/request-access.png b/docs/images/platform/project-access-requests/request-access.png new file mode 100644 index 000000000..70ad179db Binary files /dev/null and b/docs/images/platform/project-access-requests/request-access.png differ diff --git a/docs/mint.json b/docs/mint.json index 478db0276..ad8e0403e 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -161,6 +161,7 @@ "documentation/platform/access-controls/additional-privileges", "documentation/platform/access-controls/temporary-access", "documentation/platform/access-controls/access-requests", + "documentation/platform/access-controls/project-access-requests", "documentation/platform/pr-workflows", "documentation/platform/groups" ] @@ -210,7 +211,10 @@ }, { "group": "Gateway", - "pages": ["documentation/platform/gateways/overview", "documentation/platform/gateways/gateway-security"] + "pages": [ + "documentation/platform/gateways/overview", + "documentation/platform/gateways/gateway-security" + ] }, "documentation/platform/project-templates", { diff --git a/frontend/src/components/v2/PageHeader/PageHeader.tsx b/frontend/src/components/v2/PageHeader/PageHeader.tsx index 337011b38..49c10805c 100644 --- a/frontend/src/components/v2/PageHeader/PageHeader.tsx +++ b/frontend/src/components/v2/PageHeader/PageHeader.tsx @@ -9,12 +9,12 @@ type Props = { }; export const PageHeader = ({ title, description, children, className }: Props) => ( -
+

{title}

-
{children}
+
{children}
{description}
diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index 665462e7d..3f5209aca 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -3,6 +3,7 @@ export { useDeleteGroupFromWorkspace, useLeaveProject, useMigrateProjectToV3, + useRequestProjectAccess, useUpdateGroupWorkspaceRole } from "./mutations"; export { @@ -36,6 +37,7 @@ export { useListWorkspaceSshCertificates, useListWorkspaceSshCertificateTemplates, useNameWorkspaceSecrets, + useSearchProjects, useToggleAutoCapitalization, useUpdateIdentityWorkspaceRole, useUpdateProject, diff --git a/frontend/src/hooks/api/workspace/mutations.tsx b/frontend/src/hooks/api/workspace/mutations.tsx index 1ae0efe08..56f83f601 100644 --- a/frontend/src/hooks/api/workspace/mutations.tsx +++ b/frontend/src/hooks/api/workspace/mutations.tsx @@ -107,3 +107,13 @@ export const useMigrateProjectToV3 = () => { } }); }; + +export const useRequestProjectAccess = () => { + return useMutation({ + mutationFn: ({ projectId, comment }) => { + return apiRequest.post(`/api/v1/workspace/${projectId}/project-access`, { + comment + }); + } + }); +}; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 0b154b78b..ae832520e 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -32,6 +32,7 @@ import { TGetUpgradeProjectStatusDTO, TListProjectIdentitiesDTO, ToggleAutoCapitalizationDTO, + TSearchProjectsDTO, TUpdateWorkspaceIdentityRoleDTO, TUpdateWorkspaceUserRoleDTO, UpdateAuditLogsRetentionDTO, @@ -145,14 +146,31 @@ export const useGetWorkspaceById = ( export const useGetUserWorkspaces = ({ includeRoles, - type = "all" + type = "all", + options = {} }: { includeRoles?: boolean; type?: ProjectType | "all"; + options?: { enabled?: boolean }; } = {}) => useQuery({ queryKey: workspaceKeys.getAllUserWorkspace(type), - queryFn: () => fetchUserWorkspaces(includeRoles, type) + queryFn: () => fetchUserWorkspaces(includeRoles, type), + ...options + }); + +export const useSearchProjects = ({ options, ...dto }: TSearchProjectsDTO) => + useQuery({ + queryKey: workspaceKeys.searchWorkspace(dto), + queryFn: async () => { + const { data } = await apiRequest.post<{ + projects: (Workspace & { isMember: boolean })[]; + totalCount: number; + }>("/api/v1/workspace/search", dto); + + return data; + }, + ...options }); const fetchUserWorkspaceMemberships = async (orgId: string) => { diff --git a/frontend/src/hooks/api/workspace/query-keys.tsx b/frontend/src/hooks/api/workspace/query-keys.tsx index 44fee901c..7ef482a20 100644 --- a/frontend/src/hooks/api/workspace/query-keys.tsx +++ b/frontend/src/hooks/api/workspace/query-keys.tsx @@ -1,4 +1,4 @@ -import { TListProjectIdentitiesDTO } from "@app/hooks/api/workspace/types"; +import { TListProjectIdentitiesDTO, TSearchProjectsDTO } from "@app/hooks/api/workspace/types"; import type { CaStatus } from "../ca"; @@ -28,6 +28,7 @@ export const workspaceKeys = { ...params }: TListProjectIdentitiesDTO) => [...workspaceKeys.getWorkspaceIdentityMemberships(workspaceId), params] as const, + searchWorkspace: (dto: TSearchProjectsDTO) => ["search-projects", dto] as const, getWorkspaceGroupMemberships: (workspaceId: string) => [{ workspaceId }, "workspace-groups"] as const, getWorkspaceCas: ({ projectSlug }: { projectSlug: string }) => diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index 0980bc715..afeb9a6c4 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -173,3 +173,12 @@ export type TListProjectIdentitiesDTO = { export enum ProjectIdentityOrderBy { Name = "name" } +export type TSearchProjectsDTO = { + type?: ProjectType; + name?: string; + limit?: number; + offset?: number; + options?: { enabled?: boolean }; + orderBy?: ProjectIdentityOrderBy; + orderDirection?: OrderByDirection; +}; diff --git a/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx b/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx index a4ff5fff1..e31018334 100644 --- a/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar.tsx @@ -14,6 +14,7 @@ import { faPlug, faSignOut, faUser, + faUserCog, faUsers } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -385,6 +386,19 @@ export const MinimizedOrgSidebar = () => { Organization Settings + Admin Panels + {user?.superAdmin && ( + + }> + Server Admin Console + + + )} + + }> + Organization Admin Console + +
@@ -541,18 +555,6 @@ export const MinimizedOrgSidebar = () => { /> - {user?.superAdmin && ( - - - Server Admin Console - - - )} -
- - Organization Admin Console - -
}> Log Out diff --git a/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx b/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx index b245260c6..61c4f4411 100644 --- a/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx @@ -91,7 +91,8 @@ export const AccessManagementPage = () => {

We've developed an improved privilege management system to better serve your security needs. Upgrade to our new permission-based approach that allows you to - explicitly designate who can modify specific access levels, rather than relying on hierarchy comparisons. + explicitly designate who can modify specific access levels, rather than relying on + hierarchy comparisons.

-
- - ); - - const renderProjectListItem = (workspace: Workspace, isFavorite: boolean, index: number) => ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -
{ - navigate({ - to: getProjectHomePage(workspace), - params: { - projectId: workspace.id - } - }); - }} - key={workspace.id} - className={`group grid h-14 min-w-72 cursor-pointer grid-cols-6 border-l border-r border-t border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${ - index === 0 && "rounded-t-md" - }`} - > -
-
{workspace.name}
-
-
-
- {workspace.environments?.length || 0} environments -
- {isFavorite ? ( - { - e.stopPropagation(); - removeProjectFromFavorites(workspace.id); - }} - /> - ) : ( - { - e.stopPropagation(); - addProjectToFavorites(workspace.id); - }} - /> - )} -
-
- ); - - let projectsComponents: ReactNode; - - if (filteredWorkspaces.length || isProjectViewLoading) { - switch (projectsViewMode) { - case ProjectsViewMode.GRID: - projectsComponents = ( -
- {isProjectViewLoading && - Array.apply(0, Array(3)).map((_x, i) => ( -
-
- -
-
- -
-
- -
-
- ))} - {!isProjectViewLoading && ( - <> - {workspacesWithFaveProp.map((workspace) => - renderProjectGridItem(workspace, workspace.isFavorite) - )} - - )} -
- ); - - break; - case ProjectsViewMode.LIST: - default: - projectsComponents = ( -
- {isProjectViewLoading && - Array.apply(0, Array(3)).map((_x, i) => ( -
- -
- ))} - {!isProjectViewLoading && - workspacesWithFaveProp.map((workspace, ind) => - renderProjectListItem(workspace, workspace.isFavorite, ind) - )} -
- ); - break; - } - } else if (orgWorkspaces.length && searchFilter) { - projectsComponents = ( -
- -
No projects match search...
-
- ); - } - return (
@@ -395,63 +80,7 @@ export const ProductOverviewPage = ({ type }: Props) => {
)}
- -
- setSearchFilter(e.target.value)} - leftIcon={} - /> -
- - - - - -
-
- { - localStorage.setItem("projectsViewMode", ProjectsViewMode.GRID); - setProjectsViewMode(ProjectsViewMode.GRID); - }} - ariaLabel="grid" - size="xs" - className={`${ - projectsViewMode === ProjectsViewMode.GRID ? "bg-mineshaft-500" : "bg-transparent" - } min-w-[2.4rem] border-none hover:bg-mineshaft-600`} - > - - - { - localStorage.setItem("projectsViewMode", ProjectsViewMode.LIST); - setProjectsViewMode(ProjectsViewMode.LIST); - }} - ariaLabel="list" - size="xs" - className={`${ - projectsViewMode === ProjectsViewMode.LIST ? "bg-mineshaft-500" : "bg-transparent" - } min-w-[2.4rem] border-none hover:bg-mineshaft-600`} - > - - -
+ {(isAllowed) => ( )} -
- {projectsComponents} - {!isProjectViewLoading && Boolean(filteredWorkspaces.length) && ( - - )} - {isWorkspaceEmpty && ( -
- -
- You are not part of any projects in this organization yet. When you are, they will - appear here. -
-
- Create a new project, or ask other organization members to give you necessary - permissions. -
-
- )} +
+ {projectListView === ProjectListView.MyProjects && ( + + )} + {projectListView === ProjectListView.AllProjects && ( + + )} handlePopUpToggle("addNewWs", isOpen)} diff --git a/frontend/src/pages/organization/SecretManagerOverviewPage/components/AllProjectView.tsx b/frontend/src/pages/organization/SecretManagerOverviewPage/components/AllProjectView.tsx new file mode 100644 index 000000000..5cdaa9c62 --- /dev/null +++ b/frontend/src/pages/organization/SecretManagerOverviewPage/components/AllProjectView.tsx @@ -0,0 +1,279 @@ +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { + faArrowDownAZ, + faBorderAll, + faCheck, + faFolderOpen, + faList, + faMagnifyingGlass +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate } from "@tanstack/react-router"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + IconButton, + Input, + Modal, + ModalContent, + Pagination, + Skeleton, + Tooltip +} from "@app/components/v2"; +import { getProjectHomePage } from "@app/helpers/project"; +import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; +import { useRequestProjectAccess, useSearchProjects } from "@app/hooks/api"; +import { ProjectType, Workspace } from "@app/hooks/api/workspace/types"; + +import { ProjectListToggle, ProjectListView } from "./ProjectListToggle"; + +type Props = { + type: ProjectType; + onListViewToggle: (value: ProjectListView) => void; +}; + +type RequestAccessModalProps = { + projectId: string; + onPopUpToggle: () => void; +}; + +const RequestAccessModal = ({ projectId, onPopUpToggle }: RequestAccessModalProps) => { + const form = useForm<{ note: string }>(); + + const requestProjectAccess = useRequestProjectAccess(); + + const onFormSubmit = ({ note }: { note: string }) => { + if (requestProjectAccess.isPending) return; + requestProjectAccess.mutate( + { + comment: note, + projectId + }, + { + onSuccess: () => { + createNotification({ + type: "success", + title: "Project Access Request Sent", + text: "Project admins will receive an email of your request" + }); + onPopUpToggle(); + } + } + ); + }; + + return ( +
+ + + +
+ + +
+
+ ); +}; + +export const AllProjectView = ({ type, onListViewToggle }: Props) => { + const navigate = useNavigate(); + const [searchFilter, setSearchFilter] = useState(""); + const [debouncedSearch] = useDebounce(searchFilter); + const { + setPage, + perPage, + setPerPage, + page, + offset, + limit, + toggleOrderDirection, + orderDirection + } = usePagination("name", { initPerPage: 50 }); + const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp([ + "requestAccessConfirmation" + ] as const); + + const { data: searchedProjects, isPending: isProjectLoading } = useSearchProjects({ + limit, + offset, + type, + name: debouncedSearch || undefined, + orderDirection + }); + + useResetPageHelper({ + setPage, + offset, + totalCount: searchedProjects?.totalCount || 0 + }); + const requestedWorkspaceDetails = (popUp.requestAccessConfirmation.data || {}) as Workspace; + + return ( +
+
+ +
+ setSearchFilter(e.target.value)} + leftIcon={} + /> +
+ + + + + +
+
+ + + + + + + + +
+
+
+ {isProjectLoading && + Array.apply(0, Array(3)).map((_x, i) => ( +
+ +
+ ))} + {!isProjectLoading && + searchedProjects?.projects?.map((workspace) => ( +
{ + if (evt.key === "Enter" && workspace.isMember) { + navigate({ + to: getProjectHomePage(workspace), + params: { + projectId: workspace.id + } + }); + } + }} + onClick={() => { + if (workspace.isMember) { + navigate({ + to: getProjectHomePage(workspace), + params: { + projectId: workspace.id + } + }); + } + }} + key={workspace.id} + className={twMerge( + "group flex min-w-72 grid-cols-6 items-center justify-center border-l border-r border-t border-mineshaft-600 bg-mineshaft-800 px-6 py-3 first:rounded-t-md", + workspace.isMember ? "cursor-pointer hover:bg-mineshaft-700" : "cursor-default" + )} + > +
+
+
+ {workspace.name} +
+
+ {workspace.isMember ? ( +
+ + Joined +
+ ) : ( +
+ +
+ )} +
+
+
+ {workspace.description} +
+
+
+ ))} +
+ {!isProjectLoading && Boolean(searchedProjects?.totalCount) && ( + + )} + {!isProjectLoading && !searchedProjects?.totalCount && ( +
+ +
No Projects Found
+
+ )} + handlePopUpToggle("requestAccessConfirmation", isOpen)} + > + + handlePopUpToggle("requestAccessConfirmation")} + projectId={requestedWorkspaceDetails?.id} + /> + + +
+ ); +}; diff --git a/frontend/src/pages/organization/SecretManagerOverviewPage/components/MyProjectView.tsx b/frontend/src/pages/organization/SecretManagerOverviewPage/components/MyProjectView.tsx new file mode 100644 index 000000000..2eecb540d --- /dev/null +++ b/frontend/src/pages/organization/SecretManagerOverviewPage/components/MyProjectView.tsx @@ -0,0 +1,414 @@ +import { ReactNode, useMemo, useState } from "react"; +import { faFolderOpen, faStar } from "@fortawesome/free-regular-svg-icons"; +import { + faArrowDownAZ, + faArrowRight, + faArrowUpZA, + faBorderAll, + faList, + faMagnifyingGlass, + faSearch, + faStar as faSolidStar +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate } from "@tanstack/react-router"; + +import { createNotification } from "@app/components/notifications"; +import { IconButton, Input, Pagination, Skeleton, Tooltip } from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { getProjectHomePage } from "@app/helpers/project"; +import { usePagination, useResetPageHelper } from "@app/hooks"; +import { useGetUserWorkspaces } from "@app/hooks/api"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { useUpdateUserProjectFavorites } from "@app/hooks/api/users/mutation"; +import { useGetUserProjectFavorites } from "@app/hooks/api/users/queries"; +import { ProjectType, Workspace } from "@app/hooks/api/workspace/types"; + +import { ProjectListToggle, ProjectListView } from "./ProjectListToggle"; + +type Props = { + type: ProjectType; + onListViewToggle: (value: ProjectListView) => void; +}; + +enum ProjectOrderBy { + Name = "name" +} + +enum ProjectsViewMode { + GRID = "grid", + LIST = "list" +} + +export const MyProjectView = ({ type, onListViewToggle }: Props) => { + const navigate = useNavigate(); + const { currentOrg } = useOrganization(); + + const { data: workspaces = [], isPending: isWorkspaceLoading } = useGetUserWorkspaces({ + type + }); + const { + setPage, + perPage, + setPerPage, + page, + offset, + limit, + toggleOrderDirection, + orderDirection + } = usePagination(ProjectOrderBy.Name, { initPerPage: 24 }); + const { data: projectFavorites, isPending: isProjectFavoritesLoading } = + useGetUserProjectFavorites(currentOrg?.id); + + const [projectsViewMode, setProjectsViewMode] = useState( + (localStorage.getItem("projectsViewMode") as ProjectsViewMode) || ProjectsViewMode.GRID + ); + const [searchFilter, setSearchFilter] = useState(""); + + const isProjectViewLoading = isWorkspaceLoading || isProjectFavoritesLoading; + const isWorkspaceEmpty = !isProjectViewLoading && workspaces?.length === 0; + const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites(); + + const filteredWorkspaces = useMemo( + () => + workspaces + .filter((ws) => ws?.name?.toLowerCase().includes(searchFilter.toLowerCase())) + .sort((a, b) => + orderDirection === OrderByDirection.ASC + ? a.name.toLowerCase().localeCompare(b.name.toLowerCase()) + : b.name.toLowerCase().localeCompare(a.name.toLowerCase()) + ), + [searchFilter, orderDirection, workspaces] + ); + + useResetPageHelper({ + setPage, + offset, + totalCount: filteredWorkspaces.length + }); + + const { workspacesWithFaveProp } = useMemo(() => { + const workspacesWithFav = filteredWorkspaces + .map((w): Workspace & { isFavorite: boolean } => ({ + ...w, + isFavorite: Boolean(projectFavorites?.includes(w.id)) + })) + .sort((a, b) => Number(b.isFavorite) - Number(a.isFavorite)) + .slice(offset, limit * page); + + return { + workspacesWithFaveProp: workspacesWithFav + }; + }, [filteredWorkspaces, projectFavorites, offset, limit, page]); + + const addProjectToFavorites = async (projectId: string) => { + try { + if (currentOrg?.id) { + await updateUserProjectFavorites({ + orgId: currentOrg?.id, + projectFavorites: [...(projectFavorites || []), projectId] + }); + } + } catch { + createNotification({ + text: "Failed to add project to favorites.", + type: "error" + }); + } + }; + const removeProjectFromFavorites = async (projectId: string) => { + try { + if (currentOrg?.id) { + await updateUserProjectFavorites({ + orgId: currentOrg?.id, + projectFavorites: [...(projectFavorites || []).filter((entry) => entry !== projectId)] + }); + } + } catch { + createNotification({ + text: "Failed to remove project from favorites.", + type: "error" + }); + } + }; + + const renderProjectGridItem = (workspace: Workspace, isFavorite: boolean) => ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events +
{ + navigate({ + to: getProjectHomePage(workspace), + params: { + projectId: workspace.id + } + }); + }} + key={workspace.id} + className="flex h-40 min-w-72 cursor-pointer flex-col rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4" + > +
+
{workspace.name}
+ {isFavorite ? ( + { + e.stopPropagation(); + removeProjectFromFavorites(workspace.id); + }} + /> + ) : ( + { + e.stopPropagation(); + addProjectToFavorites(workspace.id); + }} + /> + )} +
+ +
+ {workspace.description} +
+ +
+ {type === ProjectType.SecretManager && ( +
+ {workspace.environments?.length || 0} environments +
+ )} + +
+
+ ); + const renderProjectListItem = (workspace: Workspace, isFavorite: boolean, index: number) => ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events +
{ + navigate({ + to: getProjectHomePage(workspace), + params: { + projectId: workspace.id + } + }); + }} + key={workspace.id} + className={`group grid h-14 min-w-72 cursor-pointer grid-cols-6 border-l border-r border-t border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${ + index === 0 && "rounded-t-md" + }`} + > +
+
{workspace.name}
+
+
+ {type === ProjectType.SecretManager && ( +
+ {workspace.environments?.length || 0} environments +
+ )} + {isFavorite ? ( + { + e.stopPropagation(); + removeProjectFromFavorites(workspace.id); + }} + /> + ) : ( + { + e.stopPropagation(); + addProjectToFavorites(workspace.id); + }} + /> + )} +
+
+ ); + let projectsComponents: ReactNode; + + if (filteredWorkspaces.length || isProjectViewLoading) { + switch (projectsViewMode) { + case ProjectsViewMode.GRID: + projectsComponents = ( +
+ {isProjectViewLoading && + Array.apply(0, Array(3)).map((_x, i) => ( +
+
+ +
+
+ +
+
+ +
+
+ ))} + {!isProjectViewLoading && ( + <> + {workspacesWithFaveProp.map((workspace) => + renderProjectGridItem(workspace, workspace.isFavorite) + )} + + )} +
+ ); + + break; + case ProjectsViewMode.LIST: + default: + projectsComponents = ( +
+ {isProjectViewLoading && + Array.apply(0, Array(3)).map((_x, i) => ( +
+ +
+ ))} + {!isProjectViewLoading && + workspacesWithFaveProp.map((workspace, ind) => + renderProjectListItem(workspace, workspace.isFavorite, ind) + )} +
+ ); + break; + } + } else if (workspaces.length && searchFilter) { + projectsComponents = ( +
+ +
No projects match search...
+
+ ); + } + return ( +
+
+ +
+ setSearchFilter(e.target.value)} + leftIcon={} + /> +
+ + + + + +
+
+ { + localStorage.setItem("projectsViewMode", ProjectsViewMode.GRID); + setProjectsViewMode(ProjectsViewMode.GRID); + }} + ariaLabel="grid" + size="xs" + className={`${ + projectsViewMode === ProjectsViewMode.GRID ? "bg-mineshaft-500" : "bg-transparent" + } min-w-[2.4rem] border-none hover:bg-mineshaft-600`} + > + + + { + localStorage.setItem("projectsViewMode", ProjectsViewMode.LIST); + setProjectsViewMode(ProjectsViewMode.LIST); + }} + ariaLabel="list" + size="xs" + className={`${ + projectsViewMode === ProjectsViewMode.LIST ? "bg-mineshaft-500" : "bg-transparent" + } min-w-[2.4rem] border-none hover:bg-mineshaft-600`} + > + + +
+
+ {projectsComponents} + {!isProjectViewLoading && Boolean(filteredWorkspaces.length) && ( + + )} + {isWorkspaceEmpty && ( +
+ +
+ You are not part of any projects in this organization yet. When you are, they will + appear here. +
+
+ Create a new project, or ask other organization members to give you necessary + permissions. +
+
+ )} +
+ ); +}; diff --git a/frontend/src/pages/organization/SecretManagerOverviewPage/components/ProjectListToggle.tsx b/frontend/src/pages/organization/SecretManagerOverviewPage/components/ProjectListToggle.tsx new file mode 100644 index 000000000..4c16f8cfb --- /dev/null +++ b/frontend/src/pages/organization/SecretManagerOverviewPage/components/ProjectListToggle.tsx @@ -0,0 +1,20 @@ +import { Select, SelectItem } from "@app/components/v2"; + +export enum ProjectListView { + MyProjects = "my-projects", + AllProjects = "all-projects" +} + +type Props = { + value: ProjectListView; + onChange: (value: ProjectListView) => void; +}; + +export const ProjectListToggle = ({ value, onChange }: Props) => { + return ( + + ); +}; diff --git a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx index 9afedcbc5..fa4249fc4 100644 --- a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx @@ -1,11 +1,20 @@ -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { Controller, useFieldArray, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useNavigate, useSearch } from "@tanstack/react-router"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { Button, FilterableSelect, FormControl, Modal, ModalContent } from "@app/components/v2"; +import { + Alert, + AlertDescription, + Button, + FilterableSelect, + FormControl, + Modal, + ModalContent +} from "@app/components/v2"; import { CreatableSelect } from "@app/components/v2/CreatableSelect"; import { OrgPermissionActions, @@ -48,7 +57,12 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { const { t } = useTranslation(); const { currentOrg } = useOrganization(); const { currentWorkspace } = useWorkspace(); + const navigate = useNavigate({ from: "" }); const { permission } = useOrgPermission(); + const requesterEmail = useSearch({ + strict: false, + select: (el) => el?.requesterEmail + }); const orgId = currentOrg?.id || ""; const workspaceId = currentWorkspace?.id || ""; @@ -62,6 +76,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { control, handleSubmit, reset, + setValue, watch, formState: { isSubmitting, errors } } = useForm({ @@ -71,6 +86,12 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { const { mutateAsync: addMembersToProject } = useAddUsersToOrg(); + useEffect(() => { + if (requesterEmail) { + handlePopUpToggle("addMember", true); + } + }, [requesterEmail]); + const onAddMembers = async ({ orgMemberships, projectRoleSlugs }: TAddMemberForm) => { if (!currentWorkspace) return; if (!currentOrg?.id) return; @@ -126,12 +147,13 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { reset(); }; - const filteredOrgUsers = useMemo(() => { + const { append } = useFieldArray({ control, name: "orgMemberships" }); + const projectInviteList = useMemo(() => { const wsUserUsernames = new Map(); members?.forEach((member) => { wsUserUsernames.set(member.user.username, true); }); - return (orgUsers || []) + const list = (orgUsers || []) .filter(({ user: u }) => !wsUserUsernames.has(u.username)) .map(({ id, inviteEmail, user: { firstName, lastName, email } }) => ({ value: id, @@ -140,13 +162,31 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { ? `${firstName} ${lastName}` : firstName || lastName || email || inviteEmail })); + const requesterStatus = { isProjectUser: wsUserUsernames.has(requesterEmail), userLabel: "" }; + if (!requesterStatus.isProjectUser) { + const userDetails = orgUsers?.find((el) => el.user.username === requesterEmail); + if (userDetails) { + const { user } = userDetails; + const label = + user.firstName && user.lastName + ? `${user.firstName} ${user.lastName}` + : user.firstName || user.lastName || (user.email as string); + requesterStatus.userLabel = label; + setValue("orgMemberships", [ + { + value: userDetails.id, + label + } + ]); + } + } + + return { list, requesterStatus }; }, [orgUsers, members]); const selectedOrgMemberships = watch("orgMemberships"); const selectedRoleSlugs = watch("projectRoleSlugs"); - const { append } = useFieldArray({ control, name: "orgMemberships" }); - const canInviteNewMembers = permission.can( OrgPermissionActions.Create, OrgPermissionSubjects.Member @@ -155,7 +195,13 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { return ( handlePopUpToggle("addMember", isOpen)} + onOpenChange={(isOpen) => { + if (!isOpen) + navigate({ + search: (prev) => ({ ...prev, requesterEmail: "" }) + }); + handlePopUpToggle("addMember", isOpen); + }} > { noOptionsMessage={() => ( <>

- {!filteredOrgUsers.length && ( + {!projectInviteList.list.length && (

All organization members are already assigned to this project.

)}

@@ -221,7 +267,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { placeholder="Add one or more users..." isMulti name="members" - options={filteredOrgUsers} + options={projectInviteList.list} value={field.value} onChange={field.onChange} /> @@ -231,7 +277,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { placeholder="Add one or more users..." isMulti name="members" - options={filteredOrgUsers} + options={projectInviteList.list} value={field.value} onChange={field.onChange} /> @@ -239,7 +285,6 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { )} /> - { )} /> + {requesterEmail && projectInviteList.requesterStatus.isProjectUser && ( + + Requested user is part of the project. + + )} + {requesterEmail && !projectInviteList.requesterStatus.isProjectUser && ( + + + Assign a role to provide access to requesting user{" "} + {projectInviteList.requesterStatus.userLabel}. + + + )}