Compare commits
16 Commits
doc/improv
...
misc/add-d
Author | SHA1 | Date | |
---|---|---|---|
2e40ee76d0 | |||
9b083a5dfb | |||
2a8e159f51 | |||
954e94cd87 | |||
9dd2379fb3 | |||
6bf9ab5937 | |||
ee536717c0 | |||
a0cb4889ca | |||
271a8de4c0 | |||
b18f7b957d | |||
e6349474aa | |||
d6da108e32 | |||
93baf9728b | |||
ecd39abdc1 | |||
d8313a161e | |||
0088217fa9 |
85
backend/Dockerfile.dev.fips
Normal 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
@ -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
|
@ -1,4 +1,5 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
|
@ -233,3 +233,8 @@ export enum ActionProjectType {
|
||||
// project operations that happen on all types
|
||||
Any = "any"
|
||||
}
|
||||
|
||||
export enum SortDirection {
|
||||
ASC = "asc",
|
||||
DESC = "desc"
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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."
|
||||
|
@ -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
|
||||
|
@ -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({
|
||||
|
@ -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" };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@ -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 };
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
};
|
||||
};
|
||||
|
@ -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
|
||||
};
|
||||
};
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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 {
|
||||
|
@ -49,4 +49,4 @@
|
||||
{{emailFooter}}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
@ -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>
|
26
backend/src/services/smtp/templates/projectAccess.handlebars
Normal 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>
|
@ -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
|
||||
|
||||

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

|
||||
|
||||
2. Add a comment explaining why you need access
|
||||

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

|
||||
|
||||
@ -20,12 +20,9 @@ The Projects tab lists all the projects within your organization, including thos
|
||||
|
||||

|
||||
|
||||
|
||||
### 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.
|
||||
|
||||

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

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

|
||||
|
||||
## 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.
|
||||
|
||||

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

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

|
||||
|
||||
|
Before Width: | Height: | Size: 852 KiB After Width: | Height: | Size: 307 KiB |
Before Width: | Height: | Size: 852 KiB After Width: | Height: | Size: 320 KiB |
BIN
docs/images/platform/project-access-requests/access-comment.png
Normal file
After Width: | Height: | Size: 300 KiB |
After Width: | Height: | Size: 251 KiB |
BIN
docs/images/platform/project-access-requests/request-access.png
Normal file
After Width: | Height: | Size: 256 KiB |
@ -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",
|
||||
{
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
2
frontend/src/components/v2/PasswordGenerator/index.tsx
Normal file
@ -0,0 +1,2 @@
|
||||
export type { PasswordGeneratorProps } from "./PasswordGenerator";
|
||||
export { PasswordGenerator } from "./PasswordGenerator";
|
237
frontend/src/components/v2/Slider/Slider.tsx
Normal 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";
|
2
frontend/src/components/v2/Slider/index.tsx
Normal file
@ -0,0 +1,2 @@
|
||||
export type { SliderProps } from "./Slider";
|
||||
export { Slider } from "./Slider";
|
@ -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";
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@ -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) => {
|
||||
|
@ -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 }) =>
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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
|
||||
|
@ -91,7 +91,8 @@ export const AccessManagementPage = () => {
|
||||
<p className="mb-2 mt-1 text-sm text-bunker-300">
|
||||
We've developed an improved privilege management system to better serve your
|
||||
security needs. Upgrade to our new permission-based approach that allows you to
|
||||
explicitly designate who can modify specific access levels, rather than relying on hierarchy comparisons.
|
||||
explicitly designate who can modify specific access levels, rather than relying on
|
||||
hierarchy comparisons.
|
||||
</p>
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
|
@ -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)}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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
|
||||
|
@ -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: [
|
||||
|
@ -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: [
|
||||
|
@ -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: [
|
||||
|
@ -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>
|
||||
)}
|
||||
/>
|
||||
|
@ -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>
|
||||
)}
|
||||
/>
|
||||
|