Compare commits

..

16 Commits

Author SHA1 Message Date
2e40ee76d0 misc: removed possibly misleading log 2025-04-05 00:50:07 +08:00
9b083a5dfb misc: add dev setup for fips 2025-04-04 23:15:55 +08:00
2a8e159f51 Merge pull request #3354 from akhilmhdh/feat/project-access
Project access request
2025-04-04 14:18:37 +05:30
=
954e94cd87 feat: updated plurals 2025-04-04 01:04:28 +05:30
=
9dd2379fb3 fix: lint issues 2025-04-03 23:29:51 +05:30
=
6bf9ab5937 feat: updated by review comment 2025-04-03 23:28:18 +05:30
=
ee536717c0 feat: code rabbit review feedbacks 2025-04-03 23:28:18 +05:30
=
a0cb4889ca feat: updated org admin sidebar images and doc on project access request 2025-04-03 23:28:18 +05:30
=
271a8de4c0 feat: updated ui for request access feature 2025-04-03 23:28:18 +05:30
=
b18f7b957d feat: added backend logic for project access, search project endpoint, send mail for org admin project access direct 2025-04-03 23:28:18 +05:30
e6349474aa Merge pull request #3352 from Infisical/feat/addPasswordGenerator
Add new PasswordGenerator and Slider components
2025-04-03 13:01:45 -03:00
d6da108e32 Merge pull request #3353 from Infisical/doc/improve-docs-for-secret-ref-and-notices
misc: improved docs for secret ref and notices
2025-04-02 22:45:44 +01:00
93baf9728b Small fix on PasswordGeneratorModal useMemo 2025-04-02 17:20:22 -03:00
ecd39abdc1 Remove unnecessary function call 2025-04-02 15:39:31 -03:00
d8313a161e Moved from useEffect to useMemo on PasswordGeneratorModal and remove it from secret-share 2025-04-02 15:27:05 -03:00
0088217fa9 Add new PasswordGenerator and Slider components 2025-04-02 11:42:01 -03:00
51 changed files with 1971 additions and 497 deletions

View File

@ -0,0 +1,85 @@
FROM node:20-slim
# ? Setup a test SoftHSM module. In production a real HSM is used.
ARG SOFTHSM2_VERSION=2.5.0
ENV SOFTHSM2_VERSION=${SOFTHSM2_VERSION} \
SOFTHSM2_SOURCES=/tmp/softhsm2
# Install build dependencies including python3 (required for pkcs11js and partially TDS driver)
RUN apt-get update && apt-get install -y \
build-essential \
autoconf \
automake \
git \
libtool \
libssl-dev \
python3 \
make \
g++ \
openssh-client \
curl \
pkg-config \
perl \
wget
# Install dependencies for TDS driver (required for SAP ASE dynamic secrets)
RUN apt-get install -y \
unixodbc \
unixodbc-dev \
freetds-dev \
freetds-bin \
tdsodbc
RUN printf "[FreeTDS]\nDescription = FreeTDS Driver\nDriver = /usr/lib/x86_64-linux-gnu/odbc/libtdsodbc.so\nSetup = /usr/lib/x86_64-linux-gnu/odbc/libtdsodbc.so\nFileUsage = 1\n" > /etc/odbcinst.ini
# Build and install SoftHSM2
RUN git clone https://github.com/opendnssec/SoftHSMv2.git ${SOFTHSM2_SOURCES}
WORKDIR ${SOFTHSM2_SOURCES}
RUN git checkout ${SOFTHSM2_VERSION} -b ${SOFTHSM2_VERSION} \
&& sh autogen.sh \
&& ./configure --prefix=/usr/local --disable-gost \
&& make \
&& make install
WORKDIR /root
RUN rm -fr ${SOFTHSM2_SOURCES}
# Install pkcs11-tool
RUN apt-get install -y opensc
RUN mkdir -p /etc/softhsm2/tokens && \
softhsm2-util --init-token --slot 0 --label "auth-app" --pin 1234 --so-pin 0000
WORKDIR /openssl-build
RUN wget https://www.openssl.org/source/openssl-3.1.2.tar.gz \
&& tar -xf openssl-3.1.2.tar.gz \
&& cd openssl-3.1.2 \
&& ./Configure enable-fips \
&& make \
&& make install_fips
# ? App setup
# Install Infisical CLI
RUN curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.deb.sh' | bash && \
apt-get update && \
apt-get install -y infisical=0.8.1
WORKDIR /app
COPY package.json package.json
COPY package-lock.json package-lock.json
RUN npm install
COPY . .
ENV HOST=0.0.0.0
ENV OPENSSL_CONF=/app/nodejs.cnf
ENV OPENSSL_MODULES=/usr/local/lib/ossl-modules
ENV NODE_OPTIONS=--force-fips
CMD ["npm", "run", "dev:docker"]

16
backend/nodejs.cnf Normal file
View File

@ -0,0 +1,16 @@
nodejs_conf = nodejs_init
.include /usr/local/ssl/fipsmodule.cnf
[nodejs_init]
providers = provider_sect
[provider_sect]
default = default_sect
fips = fips_sect
[default_sect]
activate = 1
[algorithm_sect]
default_properties = fips=yes

View File

@ -1,4 +1,5 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {

View File

@ -233,3 +233,8 @@ export enum ActionProjectType {
// project operations that happen on all types
Any = "any"
}
export enum SortDirection {
ASC = "asc",
DESC = "desc"
}

View File

@ -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;

View File

@ -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."

View File

@ -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

View File

@ -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({

View File

@ -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" };
}
});
};

View File

@ -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<TPermissionServiceFactory, "getOrgPermission">;
projectDAL: Pick<TProjectDALFactory, "find" | "findById" | "findProjectGhostUser">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findOne" | "create" | "transaction" | "delete">;
projectMembershipDAL: Pick<
TProjectMembershipDALFactory,
"findOne" | "create" | "transaction" | "delete" | "findAllProjectMembers"
>;
projectKeyDAL: Pick<TProjectKeyDALFactory, "findLatestProjectKey" | "create">;
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
userDAL: Pick<TUserDALFactory, "findUserEncKeyByUserId">;
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "create" | "delete">;
smtpService: Pick<TSmtpService, "sendMail">;
};
export type TOrgAdminServiceFactory = ReturnType<typeof orgAdminServiceFactory>;
@ -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 };
};

View File

@ -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;
}

View File

@ -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<typeof projectDALFactory>;
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
};
};

View File

@ -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<TIdentityProjectMembershipRoleDALFactory, "create">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "create" | "findLatestProjectKey" | "delete" | "find" | "insertMany">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "create" | "findProjectGhostUser" | "findOne" | "delete">;
projectMembershipDAL: Pick<
TProjectMembershipDALFactory,
"create" | "findProjectGhostUser" | "findOne" | "delete" | "findAllProjectMembers"
>;
groupProjectDAL: Pick<TGroupProjectDALFactory, "delete">;
projectSlackConfigDAL: Pick<TProjectSlackConfigDALFactory, "findOne" | "transaction" | "updateById" | "create">;
slackIntegrationDAL: Pick<TSlackIntegrationDALFactory, "findById" | "findByIdWithWorkflowIntegrationDetails">;
@ -123,6 +130,7 @@ type TProjectServiceFactoryDep = {
orgService: Pick<TOrgServiceFactory, "addGhostUser">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
queueService: Pick<TQueueServiceFactory, "stopRepeatableJob">;
smtpService: Pick<TSmtpService, "sendMail">;
orgDAL: Pick<TOrgDALFactory, "findOne">;
keyStore: Pick<TKeyStoreFactory, "deleteItem">;
@ -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
};
};

View File

@ -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;
};

View File

@ -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 {

View File

@ -49,4 +49,4 @@
{{emailFooter}}
</body>
</html>
</html>

View File

@ -0,0 +1,16 @@
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<title>Organization admin issued direct access to project</title>
</head>
<body>
<h2>Infisical</h2>
<p>The organization admin {{email}} has granted direct access to the project "{{projectName}}".</p>
{{emailFooter}}
</body>
</html>

View File

@ -0,0 +1,26 @@
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<title>Project Access Request</title>
</head>
<body>
<h2>Infisical</h2>
<h2>You have a new project access request!</h2>
<ul>
<li>Requester Name: "{{requesterName}}"</li>
<li>Requester Email: "{{requesterEmail}}"</li>
<li>Project Name: "{{projectName}}"</li>
<li>Organization Name: "{{orgName}}"</li>
<li>User Note: "{{note}}"</li>
</ul>
<p>
Please click on the link below to grant access
</p>
<a href="{{callback_url}}">Grant Access</a>
{{emailFooter}}
</body>
</html>

View File

@ -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**
<Info>
Project administrators will receive email notification with details regarding
the access request.
</Info>

View File

@ -4,13 +4,13 @@ description: "View and manage resources across your organization"
---
<Note>
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.
</Note>
## 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)

View File

@ -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)

View File

@ -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.
<Info>
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.
</Info>
![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)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 852 KiB

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 852 KiB

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

View File

@ -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",
{

View File

@ -9,12 +9,12 @@ type Props = {
};
export const PageHeader = ({ title, description, children, className }: Props) => (
<div className={twMerge("mb-4", className)}>
<div className={twMerge("mb-4 w-full", className)}>
<div className="flex w-full justify-between">
<div className="w-full">
<h1 className="mr-4 text-3xl font-semibold capitalize text-white">{title}</h1>
</div>
<div>{children}</div>
<div className="flex items-center">{children}</div>
</div>
<div className="mt-2 text-gray-400">{description}</div>
</div>

View File

@ -0,0 +1,273 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { faCheck, faCopy, faKey, faRefresh } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, Checkbox, IconButton, Slider } from "@app/components/v2";
import { useTimedReset } from "@app/hooks";
type PasswordOptionsType = {
length: number;
useUppercase: boolean;
useLowercase: boolean;
useNumbers: boolean;
useSpecialChars: boolean;
};
type PasswordGeneratorModalProps = {
isOpen: boolean;
onClose: () => void;
onUsePassword?: (password: string) => void;
minLength?: number;
maxLength?: number;
};
const PasswordGeneratorModal = ({
isOpen,
onClose,
onUsePassword,
minLength = 12,
maxLength = 64
}: PasswordGeneratorModalProps) => {
const [copyText, isCopying, setCopyText] = useTimedReset<string>({
initialState: "Copy"
});
const [refresh, setRefresh] = useState(false);
const [passwordOptions, setPasswordOptions] = useState<PasswordOptionsType>({
length: minLength,
useUppercase: true,
useLowercase: true,
useNumbers: true,
useSpecialChars: true
});
const modalRef = useRef<HTMLDivElement>(null);
const generatePassword = () => {
const charset = {
uppercase: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
lowercase: "abcdefghijklmnopqrstuvwxyz",
numbers: "0123456789",
specialChars: "-_.~!*"
};
let availableChars = "";
if (passwordOptions.useUppercase) availableChars += charset.uppercase;
if (passwordOptions.useLowercase) availableChars += charset.lowercase;
if (passwordOptions.useNumbers) availableChars += charset.numbers;
if (passwordOptions.useSpecialChars) availableChars += charset.specialChars;
if (availableChars === "") availableChars = charset.lowercase + charset.numbers;
let newPassword = "";
for (let i = 0; i < passwordOptions.length; i += 1) {
const randomIndex = Math.floor(Math.random() * availableChars.length);
newPassword += availableChars[randomIndex];
}
return newPassword;
};
useEffect(() => {
if (isOpen) {
const handleClickOutside = (event: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}
return () => {};
}, [isOpen, onClose]);
const password = useMemo(() => {
return generatePassword();
}, [passwordOptions, refresh]);
const copyToClipboard = () => {
navigator.clipboard
.writeText(password)
.then(() => {
setCopyText("Copied");
})
.catch(() => {
setCopyText("Copy failed");
});
};
const usePassword = () => {
if (onUsePassword) {
onUsePassword(password);
onClose();
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
<div
ref={modalRef}
className="w-full max-w-lg rounded-lg border border-mineshaft-600 bg-mineshaft-800 shadow-xl"
>
<div className="p-6">
<h2 className="mb-1 text-xl font-semibold text-bunker-200">Password Generator</h2>
<p className="mb-6 text-sm text-bunker-400">Generate strong unique passwords</p>
<div className="relative mb-4 rounded-md bg-mineshaft-900 p-4">
<div className="flex items-center justify-between">
<div className="w-4/5 select-all break-all pr-2 font-mono text-lg">{password}</div>
<div className="flex flex-col gap-1">
<Button
size="xs"
colorSchema="secondary"
variant="outline_bg"
onClick={() => setRefresh((prev) => !prev)}
className="w-full text-bunker-300 hover:text-bunker-100"
>
<FontAwesomeIcon icon={faRefresh} className="mr-1 h-3 w-3" />
Refresh
</Button>
<Button
size="xs"
colorSchema="secondary"
variant="outline_bg"
onClick={copyToClipboard}
className="w-full text-bunker-300 hover:text-bunker-100"
>
<FontAwesomeIcon icon={isCopying ? faCheck : faCopy} className="mr-1 h-3 w-3" />
{copyText}
</Button>
</div>
</div>
</div>
<div className="mb-6">
<div className="mb-1 flex items-center justify-between">
<label htmlFor="password-length" className="text-sm text-bunker-300">
Password length: {passwordOptions.length}
</label>
</div>
<Slider
id="password-length"
min={minLength}
max={maxLength}
value={passwordOptions.length}
onChange={(value) => setPasswordOptions({ ...passwordOptions, length: value })}
className="mb-1"
aria-labelledby="password-length-label"
/>
</div>
<div className="mb-6 flex flex-row justify-between gap-2">
<Checkbox
id="useUppercase"
className="mr-2 data-[state=checked]:bg-primary"
isChecked={passwordOptions.useUppercase}
onCheckedChange={(checked) =>
setPasswordOptions({ ...passwordOptions, useUppercase: checked as boolean })
}
>
A-Z
</Checkbox>
<Checkbox
id="useLowercase"
className="mr-2 data-[state=checked]:bg-primary"
isChecked={passwordOptions.useLowercase}
onCheckedChange={(checked) =>
setPasswordOptions({ ...passwordOptions, useLowercase: checked as boolean })
}
>
a-z
</Checkbox>
<Checkbox
id="useNumbers"
className="mr-2 data-[state=checked]:bg-primary"
isChecked={passwordOptions.useNumbers}
onCheckedChange={(checked) =>
setPasswordOptions({ ...passwordOptions, useNumbers: checked as boolean })
}
>
0-9
</Checkbox>
<Checkbox
id="useSpecialChars"
className="mr-2 data-[state=checked]:bg-primary"
isChecked={passwordOptions.useSpecialChars}
onCheckedChange={(checked) =>
setPasswordOptions({ ...passwordOptions, useSpecialChars: checked as boolean })
}
>
-_.~!*
</Checkbox>
</div>
<div className="flex justify-end">
<Button size="sm" colorSchema="primary" variant="outline_bg" onClick={onClose}>
Close
</Button>
{onUsePassword && (
<Button
size="sm"
colorSchema="primary"
variant="outline_bg"
onClick={usePassword}
className="ml-2"
>
Use Password
</Button>
)}
</div>
</div>
</div>
</div>
);
};
export type PasswordGeneratorProps = {
onUsePassword?: (password: string) => void;
isDisabled?: boolean;
minLength?: number;
maxLength?: number;
};
export const PasswordGenerator = ({
onUsePassword,
isDisabled = false,
minLength = 12,
maxLength = 64
}: PasswordGeneratorProps) => {
const [showGenerator, setShowGenerator] = useState(false);
const toggleGenerator = () => {
setShowGenerator(!showGenerator);
};
return (
<>
<IconButton
ariaLabel="generate password"
colorSchema="secondary"
size="sm"
onClick={toggleGenerator}
isDisabled={isDisabled}
className="rounded text-bunker-400 transition-colors duration-150 hover:bg-mineshaft-700 hover:text-bunker-200"
>
<FontAwesomeIcon icon={faKey} />
</IconButton>
<PasswordGeneratorModal
isOpen={showGenerator}
onClose={() => setShowGenerator(false)}
onUsePassword={onUsePassword}
minLength={minLength}
maxLength={maxLength}
/>
</>
);
};

View File

@ -0,0 +1,2 @@
export type { PasswordGeneratorProps } from "./PasswordGenerator";
export { PasswordGenerator } from "./PasswordGenerator";

View File

@ -0,0 +1,237 @@
import {
forwardRef,
InputHTMLAttributes,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState
} from "react";
import { cva, VariantProps } from "cva";
import { twMerge } from "tailwind-merge";
type Props = {
min: number;
max: number;
step?: number;
isDisabled?: boolean;
isRequired?: boolean;
showValue?: boolean;
valuePosition?: "top" | "right";
containerClassName?: string;
trackClassName?: string;
fillClassName?: string;
thumbClassName?: string;
onChange?: (value: number) => void;
onChangeComplete?: (value: number) => void;
};
const sliderTrackVariants = cva("h-1 w-full bg-mineshaft-600 rounded-full relative", {
variants: {
variant: {
default: "",
thin: "h-0.5",
thick: "h-1.5"
},
isDisabled: {
true: "opacity-50 cursor-not-allowed",
false: ""
}
}
});
const sliderFillVariants = cva("absolute h-full rounded-full", {
variants: {
variant: {
default: "bg-primary-500",
secondary: "bg-secondary-500",
danger: "bg-red-500"
},
isDisabled: {
true: "opacity-50",
false: ""
}
}
});
const sliderThumbVariants = cva(
"absolute w-4 h-4 rounded-full shadow transform -translate-x-1/2 -mt-1.5 focus:outline-none",
{
variants: {
variant: {
default: "bg-primary-500 focus:ring-2 focus:ring-primary-400/50",
secondary: "bg-secondary-500 focus:ring-2 focus:ring-secondary-400/50",
danger: "bg-red-500 focus:ring-2 focus:ring-red-400/50"
},
isDisabled: {
true: "opacity-50 cursor-not-allowed",
false: "cursor-pointer"
},
size: {
sm: "w-3 h-3 -mt-1",
md: "w-4 h-4 -mt-1.5",
lg: "w-5 h-5 -mt-2"
}
}
}
);
const sliderContainerVariants = cva("relative inline-flex font-inter", {
variants: {
isFullWidth: {
true: "w-full",
false: ""
}
}
});
export type SliderProps = Omit<InputHTMLAttributes<HTMLInputElement>, "size" | "onChange"> &
VariantProps<typeof sliderTrackVariants> &
VariantProps<typeof sliderThumbVariants> &
VariantProps<typeof sliderContainerVariants> &
Props;
export const Slider = forwardRef<HTMLInputElement, SliderProps>(
(
{
className,
containerClassName,
trackClassName,
fillClassName,
thumbClassName,
min = 0,
max = 100,
step = 1,
value,
defaultValue,
isDisabled = false,
isFullWidth = true,
isRequired = false,
showValue = false,
valuePosition = "top",
variant = "default",
size = "md",
onChange,
onChangeComplete,
...props
},
ref
): JSX.Element => {
let initialValue = min;
if (value !== undefined) {
initialValue = Number(value);
} else if (defaultValue !== undefined) {
initialValue = Number(defaultValue);
}
const [currentValue, setCurrentValue] = useState<number>(initialValue);
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const percentage = Math.max(0, Math.min(100, ((currentValue - min) / (max - min)) * 100));
useImperativeHandle(ref, () => inputRef.current as HTMLInputElement, []);
useEffect(() => {
if (value !== undefined && Number(value) !== currentValue) {
setCurrentValue(Number(value));
}
}, [value, currentValue]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = Number(e.target.value);
setCurrentValue(newValue);
onChange?.(newValue);
},
[onChange]
);
const handleMouseDown = useCallback(() => {
if (!isDisabled) {
setIsDragging(true);
}
}, [isDisabled]);
const handleChangeComplete = useCallback(() => {
if (isDragging) {
onChangeComplete?.(currentValue);
setIsDragging(false);
}
}, [isDragging, currentValue, onChangeComplete]);
useEffect(() => {
if (isDragging) {
const handleGlobalMouseUp = () => handleChangeComplete();
document.addEventListener("mouseup", handleGlobalMouseUp);
document.addEventListener("touchend", handleGlobalMouseUp);
return () => {
document.removeEventListener("mouseup", handleGlobalMouseUp);
document.removeEventListener("touchend", handleGlobalMouseUp);
};
}
return () => {};
}, [isDragging, handleChangeComplete]);
const ValueDisplay = showValue ? (
<div className="text-xs text-bunker-300">{currentValue}</div>
) : null;
return (
<div
className={twMerge(
sliderContainerVariants({ isFullWidth, className: containerClassName }),
"my-2"
)}
>
{showValue && valuePosition === "top" && ValueDisplay}
<div className="relative flex w-full items-center">
<div
className={twMerge(
sliderTrackVariants({ variant, isDisabled, className: trackClassName })
)}
>
<div
className={twMerge(
sliderFillVariants({ variant, isDisabled, className: fillClassName }),
"left-0"
)}
style={{ width: `${percentage}%` }}
/>
<div
className={twMerge(
sliderThumbVariants({ variant, isDisabled, size, className: thumbClassName })
)}
style={{ left: `${percentage}%` }}
/>
</div>
<input
type="range"
min={min}
max={max}
step={step}
value={currentValue}
onChange={handleChange}
onMouseDown={handleMouseDown}
onTouchStart={handleMouseDown}
disabled={isDisabled}
required={isRequired}
ref={inputRef}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
{...props}
/>
{showValue && valuePosition === "right" && (
<div className="ml-2 text-xs text-bunker-300">{currentValue}</div>
)}
</div>
</div>
);
}
);
Slider.displayName = "Slider";

View File

@ -0,0 +1,2 @@
export type { SliderProps } from "./Slider";
export { Slider } from "./Slider";

View File

@ -24,10 +24,12 @@ export * from "./Modal";
export * from "./NoticeBanner";
export * from "./PageHeader";
export * from "./Pagination";
export * from "./PasswordGenerator";
export * from "./Popoverv2";
export * from "./SecretInput";
export * from "./Select";
export * from "./Skeleton";
export * from "./Slider";
export * from "./Spinner";
export * from "./Stepper";
export * from "./Switch";

View File

@ -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,

View File

@ -107,3 +107,13 @@ export const useMigrateProjectToV3 = () => {
}
});
};
export const useRequestProjectAccess = () => {
return useMutation<object, object, { projectId: string; comment: string }>({
mutationFn: ({ projectId, comment }) => {
return apiRequest.post(`/api/v1/workspace/${projectId}/project-access`, {
comment
});
}
});
};

View File

@ -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) => {

View File

@ -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 }) =>

View File

@ -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;
};

View File

@ -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
</DropdownMenuItem>
</Link>
<DropdownMenuLabel>Admin Panels</DropdownMenuLabel>
{user?.superAdmin && (
<Link to="/admin">
<DropdownMenuItem icon={<FontAwesomeIcon className="w-3" icon={faUserCog} />}>
Server Admin Console
</DropdownMenuItem>
</Link>
)}
<Link to="/organization/admin">
<DropdownMenuItem icon={<FontAwesomeIcon className="w-3" icon={faCog} />}>
Organization Admin Console
</DropdownMenuItem>
</Link>
</DropdownMenuContent>
</DropdownMenu>
</div>
@ -541,18 +555,6 @@ export const MinimizedOrgSidebar = () => {
/>
</DropdownMenuItem>
</a>
{user?.superAdmin && (
<Link to="/admin">
<DropdownMenuItem className="mt-1 border-t border-mineshaft-600">
Server Admin Console
</DropdownMenuItem>
</Link>
)}
<div className="mt-1 border-t border-mineshaft-600 pt-1">
<Link to="/organization/admin">
<DropdownMenuItem>Organization Admin Console</DropdownMenuItem>
</Link>
</div>
<div className="mt-1 h-1 border-t border-mineshaft-600" />
<DropdownMenuItem onClick={logOutUser} icon={<FontAwesomeIcon icon={faSignOut} />}>
Log Out

View File

@ -91,7 +91,8 @@ export const AccessManagementPage = () => {
<p className="mb-2 mt-1 text-sm text-bunker-300">
We&apos;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.
</p>
<Button
colorSchema="primary"

View File

@ -1,62 +1,22 @@
// REFACTOR(akhilmhdh): This file needs to be split into multiple components too complex
import { ReactNode, useMemo, useState } from "react";
import { useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { faFolderOpen, faStar } from "@fortawesome/free-regular-svg-icons";
import {
faArrowDownAZ,
faArrowRight,
faArrowUpZA,
faBorderAll,
faExclamationCircle,
faList,
faMagnifyingGlass,
faPlus,
faSearch,
faStar as faSolidStar
} from "@fortawesome/free-solid-svg-icons";
import { faExclamationCircle, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { NewProjectModal } from "@app/components/projects";
import {
Button,
IconButton,
Input,
PageHeader,
Pagination,
Skeleton,
Tooltip
} from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useSubscription
} 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 { fetchUserWsKey } from "@app/hooks/api/keys/queries";
import { Button, PageHeader } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
import { Workspace } from "@app/hooks/api/types";
import { useUpdateUserProjectFavorites } from "@app/hooks/api/users/mutation";
import { useGetUserProjectFavorites } from "@app/hooks/api/users/queries";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { usePopUp } from "@app/hooks/usePopUp";
enum ProjectsViewMode {
GRID = "grid",
LIST = "list"
}
enum ProjectOrderBy {
Name = "name"
}
import { AllProjectView } from "./components/AllProjectView";
import { MyProjectView } from "./components/MyProjectView";
import { ProjectListView } from "./components/ProjectListToggle";
const formatDescription = (type: ProjectType) => {
if (type === ProjectType.SecretManager)
@ -76,295 +36,20 @@ type Props = {
export const ProductOverviewPage = ({ type }: Props) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { data: workspaces, isPending: isWorkspaceLoading } = useGetUserWorkspaces({ type });
const { currentOrg } = useOrganization();
const orgWorkspaces = workspaces || [];
const { data: projectFavorites, isPending: isProjectFavoritesLoading } =
useGetUserProjectFavorites(currentOrg?.id);
const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites();
const isProjectViewLoading = isWorkspaceLoading || isProjectFavoritesLoading;
const [projectListView, setProjectListView] = useState(ProjectListView.MyProjects);
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"addNewWs",
"upgradePlan"
] as const);
const [searchFilter, setSearchFilter] = useState("");
const { data: serverDetails } = useFetchServerStatus();
const [projectsViewMode, setProjectsViewMode] = useState<ProjectsViewMode>(
(localStorage.getItem("projectsViewMode") as ProjectsViewMode) || ProjectsViewMode.GRID
);
const { subscription } = useSubscription();
const isAddingProjectsAllowed = subscription?.workspaceLimit
? subscription.workspacesUsed < subscription.workspaceLimit
: true;
const isWorkspaceEmpty = !isProjectViewLoading && orgWorkspaces?.length === 0;
const {
setPage,
perPage,
setPerPage,
page,
offset,
limit,
toggleOrderDirection,
orderDirection
} = usePagination(ProjectOrderBy.Name, { initPerPage: 24 });
const filteredWorkspaces = useMemo(
() =>
orgWorkspaces
.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, orgWorkspaces]
);
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
<div
onClick={() => {
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"
>
<div className="flex flex-row justify-between">
<div className="mt-0 truncate text-lg text-mineshaft-100">{workspace.name}</div>
{isFavorite ? (
<FontAwesomeIcon
icon={faSolidStar}
className="text-sm text-yellow-600 hover:text-mineshaft-400"
onClick={(e) => {
e.stopPropagation();
removeProjectFromFavorites(workspace.id);
}}
/>
) : (
<FontAwesomeIcon
icon={faStar}
className="text-sm text-mineshaft-400 hover:text-mineshaft-300"
onClick={(e) => {
e.stopPropagation();
addProjectToFavorites(workspace.id);
}}
/>
)}
</div>
<div
className="mb-2.5 mt-1 grow text-sm text-mineshaft-300"
style={{
overflow: "hidden",
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: 2
}}
>
{workspace.description}
</div>
<div className="flex w-full flex-row items-end justify-between place-self-end">
{type === ProjectType.SecretManager && (
<div className="mt-0 text-xs text-mineshaft-400">
{workspace.environments?.length || 0} environments
</div>
)}
<button type="button">
<div className="group ml-auto w-max cursor-pointer rounded-full border border-mineshaft-600 bg-mineshaft-900 px-4 py-2 text-sm text-mineshaft-300 transition-all hover:border-primary-500/80 hover:bg-primary-800/20 hover:text-mineshaft-200">
Explore{" "}
<FontAwesomeIcon
icon={faArrowRight}
className="pl-1.5 pr-0.5 duration-200 hover:pl-2 hover:pr-0"
/>
</div>
</button>
</div>
</div>
);
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
<div
onClick={() => {
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"
}`}
>
<div className="flex items-center sm:col-span-3 lg:col-span-4">
<div className="truncate text-sm text-mineshaft-100">{workspace.name}</div>
</div>
<div className="flex items-center justify-end sm:col-span-3 lg:col-span-2">
<div className="text-center text-sm text-mineshaft-300">
{workspace.environments?.length || 0} environments
</div>
{isFavorite ? (
<FontAwesomeIcon
icon={faSolidStar}
className="ml-6 text-sm text-yellow-600 hover:text-mineshaft-400"
onClick={(e) => {
e.stopPropagation();
removeProjectFromFavorites(workspace.id);
}}
/>
) : (
<FontAwesomeIcon
icon={faStar}
className="ml-6 text-sm text-mineshaft-400 hover:text-mineshaft-300"
onClick={(e) => {
e.stopPropagation();
addProjectToFavorites(workspace.id);
}}
/>
)}
</div>
</div>
);
let projectsComponents: ReactNode;
if (filteredWorkspaces.length || isProjectViewLoading) {
switch (projectsViewMode) {
case ProjectsViewMode.GRID:
projectsComponents = (
<div className="mt-4 grid w-full grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{isProjectViewLoading &&
Array.apply(0, Array(3)).map((_x, i) => (
<div
key={`workspace-cards-loading-${i + 1}`}
className="flex h-40 min-w-72 flex-col justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4"
>
<div className="mt-0 text-lg text-mineshaft-100">
<Skeleton className="w-3/4 bg-mineshaft-600" />
</div>
<div className="mt-0 pb-6 text-sm text-mineshaft-300">
<Skeleton className="w-1/2 bg-mineshaft-600" />
</div>
<div className="flex justify-end">
<Skeleton className="w-1/2 bg-mineshaft-600" />
</div>
</div>
))}
{!isProjectViewLoading && (
<>
{workspacesWithFaveProp.map((workspace) =>
renderProjectGridItem(workspace, workspace.isFavorite)
)}
</>
)}
</div>
);
break;
case ProjectsViewMode.LIST:
default:
projectsComponents = (
<div className="mt-4 w-full rounded-md">
{isProjectViewLoading &&
Array.apply(0, Array(3)).map((_x, i) => (
<div
key={`workspace-cards-loading-${i + 1}`}
className={`group flex h-12 min-w-72 cursor-pointer flex-row items-center justify-between border border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${
i === 0 && "rounded-t-md"
} ${i === 2 && "rounded-b-md border-b"}`}
>
<Skeleton className="w-full bg-mineshaft-600" />
</div>
))}
{!isProjectViewLoading &&
workspacesWithFaveProp.map((workspace, ind) =>
renderProjectListItem(workspace, workspace.isFavorite, ind)
)}
</div>
);
break;
}
} else if (orgWorkspaces.length && searchFilter) {
projectsComponents = (
<div className="mt-4 w-full rounded-md border border-mineshaft-700 bg-mineshaft-800 px-4 py-6 text-base text-mineshaft-300">
<FontAwesomeIcon
icon={faSearch}
className="mb-4 mt-2 w-full text-center text-5xl text-mineshaft-400"
/>
<div className="text-center font-light">No projects match search...</div>
</div>
);
}
return (
<div className="mx-auto flex max-w-7xl flex-col justify-start bg-bunker-800">
<Helmet>
@ -395,63 +80,7 @@ export const ProductOverviewPage = ({ type }: Props) => {
</div>
)}
<div className="mb-4 flex flex-col items-start justify-start">
<PageHeader title="Projects" description={formatDescription(type)} />
<div className="flex w-full flex-row">
<Input
className="h-[2.3rem] bg-mineshaft-800 text-sm placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
placeholder="Search by project name..."
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
/>
<div className="ml-2 flex rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
<Tooltip content="Toggle Sort Direction">
<IconButton
className="min-w-[2.4rem] border-none hover:bg-mineshaft-600"
ariaLabel={`Sort ${
orderDirection === OrderByDirection.ASC ? "descending" : "ascending"
}`}
variant="plain"
size="xs"
colorSchema="secondary"
onClick={toggleOrderDirection}
>
<FontAwesomeIcon
icon={orderDirection === OrderByDirection.ASC ? faArrowDownAZ : faArrowUpZA}
/>
</IconButton>
</Tooltip>
</div>
<div className="ml-2 flex rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
<IconButton
variant="outline_bg"
onClick={() => {
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`}
>
<FontAwesomeIcon icon={faBorderAll} />
</IconButton>
<IconButton
variant="outline_bg"
onClick={() => {
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`}
>
<FontAwesomeIcon icon={faList} />
</IconButton>
</div>
<PageHeader title="Projects" description={formatDescription(type)}>
<OrgPermissionCan I={OrgPermissionActions.Create} an={OrgPermissionSubjects.Workspace}>
{(isAllowed) => (
<Button
@ -471,40 +100,14 @@ export const ProductOverviewPage = ({ type }: Props) => {
</Button>
)}
</OrgPermissionCan>
</div>
{projectsComponents}
{!isProjectViewLoading && Boolean(filteredWorkspaces.length) && (
<Pagination
className={
projectsViewMode === ProjectsViewMode.GRID
? "col-span-full !justify-start border-transparent bg-transparent pl-2"
: "rounded-b-md border border-mineshaft-600"
}
perPage={perPage}
perPageList={[12, 24, 48, 96]}
count={filteredWorkspaces.length}
page={page}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{isWorkspaceEmpty && (
<div className="mt-4 w-full rounded-md border border-mineshaft-700 bg-mineshaft-800 px-4 py-6 text-base text-mineshaft-300">
<FontAwesomeIcon
icon={faFolderOpen}
className="mb-4 mt-2 w-full text-center text-5xl text-mineshaft-400"
/>
<div className="text-center font-light">
You are not part of any projects in this organization yet. When you are, they will
appear here.
</div>
<div className="mt-0.5 text-center font-light">
Create a new project, or ask other organization members to give you necessary
permissions.
</div>
</div>
)}
</PageHeader>
</div>
{projectListView === ProjectListView.MyProjects && (
<MyProjectView type={type} onListViewToggle={setProjectListView} />
)}
{projectListView === ProjectListView.AllProjects && (
<AllProjectView type={type} onListViewToggle={setProjectListView} />
)}
<NewProjectModal
isOpen={popUp.addNewWs.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addNewWs", isOpen)}

View File

@ -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 (
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<FormControl label="Note">
<Input {...form.register("note")} />
</FormControl>
<div className="mt-4 flex items-center">
<Button className="mr-4" size="sm" type="submit" isLoading={form.formState.isSubmitting}>
Submit Request
</Button>
<Button colorSchema="secondary" variant="plain" onClick={() => onPopUpToggle()}>
Cancel
</Button>
</div>
</form>
);
};
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 (
<div>
<div className="flex w-full flex-row">
<ProjectListToggle value={ProjectListView.AllProjects} onChange={onListViewToggle} />
<div className="flex-grow" />
<Input
className="h-[2.3rem] bg-mineshaft-800 text-sm placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
containerClassName="max-w-md"
placeholder="Search by project name..."
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
/>
<div className="ml-2 flex rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
<Tooltip content="Toggle Sort Direction">
<IconButton
className="min-w-[2.4rem] border-none hover:bg-mineshaft-600"
ariaLabel="Sort asc"
variant="plain"
size="xs"
colorSchema="secondary"
onClick={toggleOrderDirection}
>
<FontAwesomeIcon icon={faArrowDownAZ} />
</IconButton>
</Tooltip>
</div>
<div className="ml-2 flex rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
<Tooltip content="Disabled across All Project view.">
<IconButton
variant="outline_bg"
ariaLabel="grid"
size="xs"
className="min-w-[2.4rem] border-none bg-transparent hover:bg-mineshaft-600"
>
<FontAwesomeIcon icon={faBorderAll} />
</IconButton>
</Tooltip>
<IconButton
variant="outline_bg"
ariaLabel="list"
size="xs"
className="min-w-[2.4rem] border-none bg-mineshaft-500 hover:bg-mineshaft-600"
>
<FontAwesomeIcon icon={faList} />
</IconButton>
</div>
</div>
<div className="mt-4 w-full rounded-md">
{isProjectLoading &&
Array.apply(0, Array(3)).map((_x, i) => (
<div
key={`workspace-cards-loading-${i + 1}`}
className={twMerge(
"flex h-12 min-w-72 cursor-pointer flex-row items-center justify-between border border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700",
i === 0 && "rounded-t-md",
i === 2 && "rounded-b-md border-b"
)}
>
<Skeleton className="w-full bg-mineshaft-600" />
</div>
))}
{!isProjectLoading &&
searchedProjects?.projects?.map((workspace) => (
<div
role="button"
tabIndex={0}
onKeyDown={(evt) => {
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"
)}
>
<div className="w-full items-center">
<div className="flex flex-grow items-center">
<div className="flex-grow truncate text-sm text-mineshaft-100">
{workspace.name}
</div>
<div className="flex items-center">
{workspace.isMember ? (
<div className="flex items-center text-center text-sm text-primary">
<FontAwesomeIcon icon={faCheck} className="mr-2" />
Joined
</div>
) : (
<div className="opacity-0 transition-all group-hover:opacity-100">
<Button
size="xs"
variant="outline_bg"
onClick={() => handlePopUpOpen("requestAccessConfirmation", workspace)}
>
Request Access
</Button>
</div>
)}
</div>
</div>
<div className="mt-1 max-w-lg overflow-hidden text-ellipsis whitespace-nowrap text-xs text-mineshaft-300">
{workspace.description}
</div>
</div>
</div>
))}
</div>
{!isProjectLoading && Boolean(searchedProjects?.totalCount) && (
<Pagination
className="rounded-b-md border border-mineshaft-600"
perPage={perPage}
perPageList={[12, 24, 48, 96]}
count={searchedProjects?.totalCount || 0}
page={page}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isProjectLoading && !searchedProjects?.totalCount && (
<div className="mt-4 w-full rounded-md border border-mineshaft-700 bg-mineshaft-800 px-4 py-6 text-base text-mineshaft-300">
<FontAwesomeIcon
icon={faFolderOpen}
className="mb-4 mt-2 w-full text-center text-5xl text-mineshaft-400"
/>
<div className="text-center font-light">No Projects Found</div>
</div>
)}
<Modal
isOpen={popUp.requestAccessConfirmation.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("requestAccessConfirmation", isOpen)}
>
<ModalContent
title="Confirm Access Request"
subTitle={`Requesting access to project ${requestedWorkspaceDetails?.name}. You may include a note for the admins.`}
>
<RequestAccessModal
onPopUpToggle={() => handlePopUpToggle("requestAccessConfirmation")}
projectId={requestedWorkspaceDetails?.id}
/>
</ModalContent>
</Modal>
</div>
);
};

View File

@ -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<ProjectsViewMode>(
(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
<div
onClick={() => {
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"
>
<div className="flex flex-row justify-between">
<div className="mt-0 truncate text-lg text-mineshaft-100">{workspace.name}</div>
{isFavorite ? (
<FontAwesomeIcon
icon={faSolidStar}
className="text-sm text-yellow-600 hover:text-mineshaft-400"
onClick={(e) => {
e.stopPropagation();
removeProjectFromFavorites(workspace.id);
}}
/>
) : (
<FontAwesomeIcon
icon={faStar}
className="text-sm text-mineshaft-400 hover:text-mineshaft-300"
onClick={(e) => {
e.stopPropagation();
addProjectToFavorites(workspace.id);
}}
/>
)}
</div>
<div
className="mb-2.5 mt-1 grow text-sm text-mineshaft-300"
style={{
overflow: "hidden",
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: 2
}}
>
{workspace.description}
</div>
<div className="flex w-full flex-row items-end justify-between place-self-end">
{type === ProjectType.SecretManager && (
<div className="mt-0 text-xs text-mineshaft-400">
{workspace.environments?.length || 0} environments
</div>
)}
<button type="button">
<div className="group ml-auto w-max cursor-pointer rounded-full border border-mineshaft-600 bg-mineshaft-900 px-4 py-2 text-sm text-mineshaft-300 transition-all hover:border-primary-500/80 hover:bg-primary-800/20 hover:text-mineshaft-200">
Explore{" "}
<FontAwesomeIcon
icon={faArrowRight}
className="pl-1.5 pr-0.5 duration-200 hover:pl-2 hover:pr-0"
/>
</div>
</button>
</div>
</div>
);
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
<div
onClick={() => {
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"
}`}
>
<div className="flex items-center sm:col-span-3 lg:col-span-4">
<div className="truncate text-sm text-mineshaft-100">{workspace.name}</div>
</div>
<div className="flex items-center justify-end sm:col-span-3 lg:col-span-2">
{type === ProjectType.SecretManager && (
<div className="text-center text-sm text-mineshaft-300">
{workspace.environments?.length || 0} environments
</div>
)}
{isFavorite ? (
<FontAwesomeIcon
icon={faSolidStar}
className="ml-6 text-sm text-yellow-600 hover:text-mineshaft-400"
onClick={(e) => {
e.stopPropagation();
removeProjectFromFavorites(workspace.id);
}}
/>
) : (
<FontAwesomeIcon
icon={faStar}
className="ml-6 text-sm text-mineshaft-400 hover:text-mineshaft-300"
onClick={(e) => {
e.stopPropagation();
addProjectToFavorites(workspace.id);
}}
/>
)}
</div>
</div>
);
let projectsComponents: ReactNode;
if (filteredWorkspaces.length || isProjectViewLoading) {
switch (projectsViewMode) {
case ProjectsViewMode.GRID:
projectsComponents = (
<div className="mt-4 grid w-full grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{isProjectViewLoading &&
Array.apply(0, Array(3)).map((_x, i) => (
<div
key={`workspace-cards-loading-${i + 1}`}
className="flex h-40 min-w-72 flex-col justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4"
>
<div className="mt-0 text-lg text-mineshaft-100">
<Skeleton className="w-3/4 bg-mineshaft-600" />
</div>
<div className="mt-0 pb-6 text-sm text-mineshaft-300">
<Skeleton className="w-1/2 bg-mineshaft-600" />
</div>
<div className="flex justify-end">
<Skeleton className="w-1/2 bg-mineshaft-600" />
</div>
</div>
))}
{!isProjectViewLoading && (
<>
{workspacesWithFaveProp.map((workspace) =>
renderProjectGridItem(workspace, workspace.isFavorite)
)}
</>
)}
</div>
);
break;
case ProjectsViewMode.LIST:
default:
projectsComponents = (
<div className="mt-4 w-full rounded-md">
{isProjectViewLoading &&
Array.apply(0, Array(3)).map((_x, i) => (
<div
key={`workspace-cards-loading-${i + 1}`}
className={`group flex h-12 min-w-72 cursor-pointer flex-row items-center justify-between border border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${
i === 0 && "rounded-t-md"
} ${i === 2 && "rounded-b-md border-b"}`}
>
<Skeleton className="w-full bg-mineshaft-600" />
</div>
))}
{!isProjectViewLoading &&
workspacesWithFaveProp.map((workspace, ind) =>
renderProjectListItem(workspace, workspace.isFavorite, ind)
)}
</div>
);
break;
}
} else if (workspaces.length && searchFilter) {
projectsComponents = (
<div className="mt-4 w-full rounded-md border border-mineshaft-700 bg-mineshaft-800 px-4 py-6 text-base text-mineshaft-300">
<FontAwesomeIcon
icon={faSearch}
className="mb-4 mt-2 w-full text-center text-5xl text-mineshaft-400"
/>
<div className="text-center font-light">No projects match search...</div>
</div>
);
}
return (
<div>
<div className="flex w-full flex-row">
<ProjectListToggle value={ProjectListView.MyProjects} onChange={onListViewToggle} />
<div className="flex-grow" />
<Input
className="h-[2.3rem] bg-mineshaft-800 text-sm placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
containerClassName="max-w-md"
placeholder="Search by project name..."
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
/>
<div className="ml-2 flex rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
<Tooltip content="Toggle Sort Direction">
<IconButton
className="min-w-[2.4rem] border-none hover:bg-mineshaft-600"
ariaLabel={`Sort ${
orderDirection === OrderByDirection.ASC ? "descending" : "ascending"
}`}
variant="plain"
size="xs"
colorSchema="secondary"
onClick={toggleOrderDirection}
>
<FontAwesomeIcon
icon={orderDirection === OrderByDirection.ASC ? faArrowDownAZ : faArrowUpZA}
/>
</IconButton>
</Tooltip>
</div>
<div className="ml-2 flex rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
<IconButton
variant="outline_bg"
onClick={() => {
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`}
>
<FontAwesomeIcon icon={faBorderAll} />
</IconButton>
<IconButton
variant="outline_bg"
onClick={() => {
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`}
>
<FontAwesomeIcon icon={faList} />
</IconButton>
</div>
</div>
{projectsComponents}
{!isProjectViewLoading && Boolean(filteredWorkspaces.length) && (
<Pagination
className={
projectsViewMode === ProjectsViewMode.GRID
? "col-span-full !justify-start border-transparent bg-transparent pl-2"
: "rounded-b-md border border-mineshaft-600"
}
perPage={perPage}
perPageList={[12, 24, 48, 96]}
count={filteredWorkspaces.length}
page={page}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{isWorkspaceEmpty && (
<div className="mt-4 w-full rounded-md border border-mineshaft-700 bg-mineshaft-800 px-4 py-6 text-base text-mineshaft-300">
<FontAwesomeIcon
icon={faFolderOpen}
className="mb-4 mt-2 w-full text-center text-5xl text-mineshaft-400"
/>
<div className="text-center font-light">
You are not part of any projects in this organization yet. When you are, they will
appear here.
</div>
<div className="mt-0.5 text-center font-light">
Create a new project, or ask other organization members to give you necessary
permissions.
</div>
</div>
)}
</div>
);
};

View File

@ -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 (
<Select value={value} onValueChange={onChange}>
<SelectItem value={ProjectListView.MyProjects}>My Projects</SelectItem>
<SelectItem value={ProjectListView.AllProjects}>All Projects</SelectItem>
</Select>
);
};

View File

@ -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<TAddMemberForm>({
@ -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<TAddMemberForm>({ 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<TAddMemberForm>({ control, name: "orgMemberships" });
const canInviteNewMembers = permission.can(
OrgPermissionActions.Create,
OrgPermissionSubjects.Member
@ -155,7 +195,13 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
return (
<Modal
isOpen={popUp?.addMember?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addMember", isOpen)}
onOpenChange={(isOpen) => {
if (!isOpen)
navigate({
search: (prev) => ({ ...prev, requesterEmail: "" })
});
handlePopUpToggle("addMember", isOpen);
}}
>
<ModalContent
bodyClassName="overflow-visible"
@ -185,7 +231,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
noOptionsMessage={() => (
<>
<p>
{!filteredOrgUsers.length && (
{!projectInviteList.list.length && (
<p>All organization members are already assigned to this project.</p>
)}
</p>
@ -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) => {
</FormControl>
)}
/>
<Controller
control={control}
name="projectRoleSlugs"
@ -263,6 +308,19 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
</FormControl>
)}
/>
{requesterEmail && projectInviteList.requesterStatus.isProjectUser && (
<Alert hideTitle variant="danger">
<AlertDescription>Requested user is part of the project.</AlertDescription>
</Alert>
)}
{requesterEmail && !projectInviteList.requesterStatus.isProjectUser && (
<Alert hideTitle>
<AlertDescription>
Assign a role to provide access to requesting user{" "}
<b>{projectInviteList.requesterStatus.userLabel}</b>.
</AlertDescription>
</Alert>
)}
</div>
<div className="mt-8 flex items-center">
<Button

View File

@ -1,4 +1,4 @@
import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
@ -7,7 +7,8 @@ import { ProjectAccessControlTabs } from "@app/types/project";
import { AccessControlPage } from "./AccessControlPage";
const AccessControlPageQuerySchema = z.object({
selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member)
selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member),
requesterEmail: z.string().catch("")
});
export const Route = createFileRoute(
@ -15,6 +16,9 @@ export const Route = createFileRoute(
)({
component: AccessControlPage,
validateSearch: zodValidator(AccessControlPageQuerySchema),
search: {
middlewares: [stripSearchParams({ requesterEmail: "" })]
},
beforeLoad: ({ context, params }) => {
return {
breadcrumbs: [

View File

@ -1,4 +1,4 @@
import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
@ -7,7 +7,8 @@ import { ProjectAccessControlTabs } from "@app/types/project";
import { AccessControlPage } from "./AccessControlPage";
const AccessControlPageQuerySchema = z.object({
selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member)
selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member),
requesterEmail: z.string().catch("")
});
export const Route = createFileRoute(
@ -15,6 +16,9 @@ export const Route = createFileRoute(
)({
component: AccessControlPage,
validateSearch: zodValidator(AccessControlPageQuerySchema),
search: {
middlewares: [stripSearchParams({ requesterEmail: "" })]
},
beforeLoad: ({ context, params }) => {
return {
breadcrumbs: [

View File

@ -1,4 +1,4 @@
import { createFileRoute, linkOptions } from "@tanstack/react-router";
import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
@ -7,7 +7,8 @@ import { ProjectAccessControlTabs } from "@app/types/project";
import { AccessControlPage } from "./AccessControlPage";
const AccessControlPageQuerySchema = z.object({
selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member)
selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member),
requesterEmail: z.string().catch("")
});
export const Route = createFileRoute(
@ -15,6 +16,9 @@ export const Route = createFileRoute(
)({
component: AccessControlPage,
validateSearch: zodValidator(AccessControlPageQuerySchema),
search: {
middlewares: [stripSearchParams({ requesterEmail: "" })]
},
beforeLoad: ({ context, params }) => {
return {
breadcrumbs: [

View File

@ -7,7 +7,13 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FilterableSelect, FormControl, Input } from "@app/components/v2";
import {
Button,
FilterableSelect,
FormControl,
Input,
PasswordGenerator
} from "@app/components/v2";
import { CreatableSelect } from "@app/components/v2/CreatableSelect";
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
import {
@ -226,10 +232,13 @@ export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => {
isError={Boolean(errors?.value)}
errorText={errors?.value?.message}
>
<InfisicalSecretInput
{...field}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
<div className="flex items-center gap-2">
<InfisicalSecretInput
{...field}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
<PasswordGenerator onUsePassword={field.onChange} />
</div>
</FormControl>
)}
/>

View File

@ -6,7 +6,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input } from "@app/components/v2";
import { Button, FormControl, Input, PasswordGenerator } from "@app/components/v2";
import { CreatableSelect } from "@app/components/v2/CreatableSelect";
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
@ -162,12 +162,15 @@ export const CreateSecretForm = ({
isError={Boolean(errors?.value)}
errorText={errors?.value?.message}
>
<InfisicalSecretInput
{...field}
environment={environment}
secretPath={secretPath}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
<div className="flex items-center gap-2">
<InfisicalSecretInput
{...field}
environment={environment}
secretPath={secretPath}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
<PasswordGenerator onUsePassword={field.onChange} />
</div>
</FormControl>
)}
/>