Compare commits
10 Commits
daniel/go-
...
misc/add-d
Author | SHA1 | Date | |
---|---|---|---|
|
2e40ee76d0 | ||
|
9b083a5dfb | ||
|
2a8e159f51 | ||
|
954e94cd87 | ||
|
9dd2379fb3 | ||
|
6bf9ab5937 | ||
|
ee536717c0 | ||
|
a0cb4889ca | ||
|
271a8de4c0 | ||
|
b18f7b957d |
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 { Knex } from "knex";
|
||||||
|
|
||||||
import { TableName } from "../schemas";
|
import { TableName } from "../schemas";
|
||||||
|
|
||||||
export async function up(knex: Knex): Promise<void> {
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
@@ -233,3 +233,8 @@ export enum ActionProjectType {
|
|||||||
// project operations that happen on all types
|
// project operations that happen on all types
|
||||||
Any = "any"
|
Any = "any"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum SortDirection {
|
||||||
|
ASC = "asc",
|
||||||
|
DESC = "desc"
|
||||||
|
}
|
||||||
|
@@ -285,7 +285,9 @@ export enum EventType {
|
|||||||
KMIP_OPERATION_ACTIVATE = "kmip-operation-activate",
|
KMIP_OPERATION_ACTIVATE = "kmip-operation-activate",
|
||||||
KMIP_OPERATION_REVOKE = "kmip-operation-revoke",
|
KMIP_OPERATION_REVOKE = "kmip-operation-revoke",
|
||||||
KMIP_OPERATION_LOCATE = "kmip-operation-locate",
|
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[] = [
|
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 {
|
interface SetupKmipEvent {
|
||||||
type: EventType.SETUP_KMIP;
|
type: EventType.SETUP_KMIP;
|
||||||
metadata: {
|
metadata: {
|
||||||
@@ -2511,5 +2522,6 @@ export type Event =
|
|||||||
| KmipOperationRevokeEvent
|
| KmipOperationRevokeEvent
|
||||||
| KmipOperationLocateEvent
|
| KmipOperationLocateEvent
|
||||||
| KmipOperationRegisterEvent
|
| KmipOperationRegisterEvent
|
||||||
|
| ProjectAccessRequestEvent
|
||||||
| CreateSecretRequestEvent
|
| CreateSecretRequestEvent
|
||||||
| SecretApprovalRequestReview;
|
| SecretApprovalRequestReview;
|
||||||
|
@@ -633,7 +633,8 @@ export const FOLDERS = {
|
|||||||
path: "The path to list folders from.",
|
path: "The path to list folders from.",
|
||||||
directory: "The directory to list folders from. (Deprecated in favor of path)",
|
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.",
|
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: {
|
GET_BY_ID: {
|
||||||
folderId: "The ID of the folder to get details."
|
folderId: "The ID of the folder to get details."
|
||||||
|
@@ -36,7 +36,8 @@ export enum CharacterType {
|
|||||||
DoubleQuote = "doubleQuote", // "
|
DoubleQuote = "doubleQuote", // "
|
||||||
Comma = "comma", // ,
|
Comma = "comma", // ,
|
||||||
Semicolon = "semicolon", // ;
|
Semicolon = "semicolon", // ;
|
||||||
Exclamation = "exclamation" // !
|
Exclamation = "exclamation", // !
|
||||||
|
Fullstop = "fullStop" // .
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,7 +82,8 @@ export const characterValidator = (allowedCharacters: CharacterType[]) => {
|
|||||||
[CharacterType.DoubleQuote]: '\\"',
|
[CharacterType.DoubleQuote]: '\\"',
|
||||||
[CharacterType.Comma]: ",",
|
[CharacterType.Comma]: ",",
|
||||||
[CharacterType.Semicolon]: ";",
|
[CharacterType.Semicolon]: ";",
|
||||||
[CharacterType.Exclamation]: "!"
|
[CharacterType.Exclamation]: "!",
|
||||||
|
[CharacterType.Fullstop]: "."
|
||||||
};
|
};
|
||||||
|
|
||||||
// Combine patterns from allowed characters
|
// Combine patterns from allowed characters
|
||||||
|
@@ -662,6 +662,7 @@ export const registerRoutes = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
const orgAdminService = orgAdminServiceFactory({
|
const orgAdminService = orgAdminServiceFactory({
|
||||||
|
smtpService,
|
||||||
projectDAL,
|
projectDAL,
|
||||||
permissionService,
|
permissionService,
|
||||||
projectUserMembershipRoleDAL,
|
projectUserMembershipRoleDAL,
|
||||||
@@ -964,7 +965,8 @@ export const registerRoutes = async (
|
|||||||
projectSlackConfigDAL,
|
projectSlackConfigDAL,
|
||||||
slackIntegrationDAL,
|
slackIntegrationDAL,
|
||||||
projectTemplateService,
|
projectTemplateService,
|
||||||
groupProjectDAL
|
groupProjectDAL,
|
||||||
|
smtpService
|
||||||
});
|
});
|
||||||
|
|
||||||
const projectEnvService = projectEnvServiceFactory({
|
const projectEnvService = projectEnvServiceFactory({
|
||||||
|
@@ -8,15 +8,17 @@ import {
|
|||||||
ProjectSlackConfigsSchema,
|
ProjectSlackConfigsSchema,
|
||||||
ProjectType,
|
ProjectType,
|
||||||
SecretFoldersSchema,
|
SecretFoldersSchema,
|
||||||
|
SortDirection,
|
||||||
UserEncryptionKeysSchema,
|
UserEncryptionKeysSchema,
|
||||||
UsersSchema
|
UsersSchema
|
||||||
} from "@app/db/schemas";
|
} from "@app/db/schemas";
|
||||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||||
import { PROJECTS } from "@app/lib/api-docs";
|
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 { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
import { AuthMode } from "@app/services/auth/auth-type";
|
import { ActorType, AuthMode } from "@app/services/auth/auth-type";
|
||||||
import { ProjectFilterType } from "@app/services/project/project-types";
|
import { ProjectFilterType, SearchProjectSortBy } from "@app/services/project/project-types";
|
||||||
import { validateSlackChannelsField } from "@app/services/slack/slack-auth-validators";
|
import { validateSlackChannelsField } from "@app/services/slack/slack-auth-validators";
|
||||||
|
|
||||||
import { integrationAuthPubSchema, SanitizedProjectSchema } from "../sanitizedSchemas";
|
import { integrationAuthPubSchema, SanitizedProjectSchema } from "../sanitizedSchemas";
|
||||||
@@ -704,4 +706,106 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
|||||||
return environmentsFolders;
|
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 { TProjectKeyDALFactory } from "../project-key/project-key-dal";
|
||||||
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
|
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
|
||||||
import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-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 { TUserDALFactory } from "../user/user-dal";
|
||||||
import { TAccessProjectDTO, TListOrgProjectsDTO } from "./org-admin-types";
|
import { TAccessProjectDTO, TListOrgProjectsDTO } from "./org-admin-types";
|
||||||
|
|
||||||
type TOrgAdminServiceFactoryDep = {
|
type TOrgAdminServiceFactoryDep = {
|
||||||
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
|
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
|
||||||
projectDAL: Pick<TProjectDALFactory, "find" | "findById" | "findProjectGhostUser">;
|
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">;
|
projectKeyDAL: Pick<TProjectKeyDALFactory, "findLatestProjectKey" | "create">;
|
||||||
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
|
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
|
||||||
userDAL: Pick<TUserDALFactory, "findUserEncKeyByUserId">;
|
userDAL: Pick<TUserDALFactory, "findUserEncKeyByUserId">;
|
||||||
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "create" | "delete">;
|
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "create" | "delete">;
|
||||||
|
smtpService: Pick<TSmtpService, "sendMail">;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TOrgAdminServiceFactory = ReturnType<typeof orgAdminServiceFactory>;
|
export type TOrgAdminServiceFactory = ReturnType<typeof orgAdminServiceFactory>;
|
||||||
@@ -34,7 +39,8 @@ export const orgAdminServiceFactory = ({
|
|||||||
projectKeyDAL,
|
projectKeyDAL,
|
||||||
projectBotDAL,
|
projectBotDAL,
|
||||||
userDAL,
|
userDAL,
|
||||||
projectUserMembershipRoleDAL
|
projectUserMembershipRoleDAL,
|
||||||
|
smtpService
|
||||||
}: TOrgAdminServiceFactoryDep) => {
|
}: TOrgAdminServiceFactoryDep) => {
|
||||||
const listOrgProjects = async ({
|
const listOrgProjects = async ({
|
||||||
actor,
|
actor,
|
||||||
@@ -184,6 +190,23 @@ export const orgAdminServiceFactory = ({
|
|||||||
);
|
);
|
||||||
return newProjectMembership;
|
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 };
|
return { isExistingMember: false, membership: updatedMembership };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -231,7 +231,7 @@ export const orgServiceFactory = ({
|
|||||||
|
|
||||||
const findAllWorkspaces = async ({ actor, actorId, orgId, type }: TFindAllWorkspacesDTO) => {
|
const findAllWorkspaces = async ({ actor, actorId, orgId, type }: TFindAllWorkspacesDTO) => {
|
||||||
if (actor === ActorType.USER) {
|
if (actor === ActorType.USER) {
|
||||||
const workspaces = await projectDAL.findAllProjects(actorId, orgId, type || "all");
|
const workspaces = await projectDAL.findUserProjects(actorId, orgId, type || "all");
|
||||||
return workspaces;
|
return workspaces;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -6,20 +6,23 @@ import {
|
|||||||
ProjectType,
|
ProjectType,
|
||||||
ProjectUpgradeStatus,
|
ProjectUpgradeStatus,
|
||||||
ProjectVersion,
|
ProjectVersion,
|
||||||
|
SortDirection,
|
||||||
TableName,
|
TableName,
|
||||||
|
TProjects,
|
||||||
TProjectsUpdate
|
TProjectsUpdate
|
||||||
} from "@app/db/schemas";
|
} from "@app/db/schemas";
|
||||||
import { BadRequestError, DatabaseError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
|
import { BadRequestError, DatabaseError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
|
||||||
import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
|
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 type TProjectDALFactory = ReturnType<typeof projectDALFactory>;
|
||||||
|
|
||||||
export const projectDALFactory = (db: TDbClient) => {
|
export const projectDALFactory = (db: TDbClient) => {
|
||||||
const projectOrm = ormify(db, TableName.Project);
|
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 {
|
try {
|
||||||
const workspaces = await db
|
const workspaces = await db
|
||||||
.replicaNode()(TableName.ProjectMembership)
|
.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 {
|
return {
|
||||||
...projectOrm,
|
...projectOrm,
|
||||||
findAllProjects,
|
findUserProjects,
|
||||||
setProjectUpgradeStatus,
|
setProjectUpgradeStatus,
|
||||||
findAllProjectsByIdentity,
|
findAllProjectsByIdentity,
|
||||||
findProjectGhostUser,
|
findProjectGhostUser,
|
||||||
@@ -363,6 +436,7 @@ export const projectDALFactory = (db: TDbClient) => {
|
|||||||
findProjectBySlug,
|
findProjectBySlug,
|
||||||
findProjectWithOrg,
|
findProjectWithOrg,
|
||||||
checkProjectUpgradeStatus,
|
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 { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal";
|
||||||
import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal";
|
import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal";
|
||||||
import { TKeyStoreFactory } from "@app/keystore/keystore";
|
import { TKeyStoreFactory } from "@app/keystore/keystore";
|
||||||
|
import { getConfig } from "@app/lib/config/env";
|
||||||
import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
|
import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
|
||||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||||
import { groupBy } from "@app/lib/fn";
|
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 { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal";
|
||||||
import { TProjectSlackConfigDALFactory } from "../slack/project-slack-config-dal";
|
import { TProjectSlackConfigDALFactory } from "../slack/project-slack-config-dal";
|
||||||
import { TSlackIntegrationDALFactory } from "../slack/slack-integration-dal";
|
import { TSlackIntegrationDALFactory } from "../slack/slack-integration-dal";
|
||||||
|
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
|
||||||
import { TUserDALFactory } from "../user/user-dal";
|
import { TUserDALFactory } from "../user/user-dal";
|
||||||
import { TProjectDALFactory } from "./project-dal";
|
import { TProjectDALFactory } from "./project-dal";
|
||||||
import { assignWorkspaceKeysToMembers, createProjectKey } from "./project-fns";
|
import { assignWorkspaceKeysToMembers, createProjectKey } from "./project-fns";
|
||||||
@@ -76,6 +78,8 @@ import {
|
|||||||
TListProjectSshCertificatesDTO,
|
TListProjectSshCertificatesDTO,
|
||||||
TListProjectSshCertificateTemplatesDTO,
|
TListProjectSshCertificateTemplatesDTO,
|
||||||
TLoadProjectKmsBackupDTO,
|
TLoadProjectKmsBackupDTO,
|
||||||
|
TProjectAccessRequestDTO,
|
||||||
|
TSearchProjectsDTO,
|
||||||
TToggleProjectAutoCapitalizationDTO,
|
TToggleProjectAutoCapitalizationDTO,
|
||||||
TUpdateAuditLogsRetentionDTO,
|
TUpdateAuditLogsRetentionDTO,
|
||||||
TUpdateProjectDTO,
|
TUpdateProjectDTO,
|
||||||
@@ -106,7 +110,10 @@ type TProjectServiceFactoryDep = {
|
|||||||
identityProjectDAL: TIdentityProjectDALFactory;
|
identityProjectDAL: TIdentityProjectDALFactory;
|
||||||
identityProjectMembershipRoleDAL: Pick<TIdentityProjectMembershipRoleDALFactory, "create">;
|
identityProjectMembershipRoleDAL: Pick<TIdentityProjectMembershipRoleDALFactory, "create">;
|
||||||
projectKeyDAL: Pick<TProjectKeyDALFactory, "create" | "findLatestProjectKey" | "delete" | "find" | "insertMany">;
|
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">;
|
groupProjectDAL: Pick<TGroupProjectDALFactory, "delete">;
|
||||||
projectSlackConfigDAL: Pick<TProjectSlackConfigDALFactory, "findOne" | "transaction" | "updateById" | "create">;
|
projectSlackConfigDAL: Pick<TProjectSlackConfigDALFactory, "findOne" | "transaction" | "updateById" | "create">;
|
||||||
slackIntegrationDAL: Pick<TSlackIntegrationDALFactory, "findById" | "findByIdWithWorkflowIntegrationDetails">;
|
slackIntegrationDAL: Pick<TSlackIntegrationDALFactory, "findById" | "findByIdWithWorkflowIntegrationDetails">;
|
||||||
@@ -123,6 +130,7 @@ type TProjectServiceFactoryDep = {
|
|||||||
orgService: Pick<TOrgServiceFactory, "addGhostUser">;
|
orgService: Pick<TOrgServiceFactory, "addGhostUser">;
|
||||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||||
queueService: Pick<TQueueServiceFactory, "stopRepeatableJob">;
|
queueService: Pick<TQueueServiceFactory, "stopRepeatableJob">;
|
||||||
|
smtpService: Pick<TSmtpService, "sendMail">;
|
||||||
|
|
||||||
orgDAL: Pick<TOrgDALFactory, "findOne">;
|
orgDAL: Pick<TOrgDALFactory, "findOne">;
|
||||||
keyStore: Pick<TKeyStoreFactory, "deleteItem">;
|
keyStore: Pick<TKeyStoreFactory, "deleteItem">;
|
||||||
@@ -177,7 +185,8 @@ export const projectServiceFactory = ({
|
|||||||
projectSlackConfigDAL,
|
projectSlackConfigDAL,
|
||||||
slackIntegrationDAL,
|
slackIntegrationDAL,
|
||||||
projectTemplateService,
|
projectTemplateService,
|
||||||
groupProjectDAL
|
groupProjectDAL,
|
||||||
|
smtpService
|
||||||
}: TProjectServiceFactoryDep) => {
|
}: TProjectServiceFactoryDep) => {
|
||||||
/*
|
/*
|
||||||
* Create workspace. Make user the admin
|
* Create workspace. Make user the admin
|
||||||
@@ -506,7 +515,7 @@ export const projectServiceFactory = ({
|
|||||||
actorOrgId,
|
actorOrgId,
|
||||||
type = ProjectType.SecretManager
|
type = ProjectType.SecretManager
|
||||||
}: TListProjectsDTO) => {
|
}: TListProjectsDTO) => {
|
||||||
const workspaces = await projectDAL.findAllProjects(actorId, actorOrgId, type);
|
const workspaces = await projectDAL.findUserProjects(actorId, actorOrgId, type);
|
||||||
|
|
||||||
if (includeRoles) {
|
if (includeRoles) {
|
||||||
const { permission } = await permissionService.getUserOrgPermission(
|
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 {
|
return {
|
||||||
createProject,
|
createProject,
|
||||||
deleteProject,
|
deleteProject,
|
||||||
@@ -1364,6 +1452,8 @@ export const projectServiceFactory = ({
|
|||||||
loadProjectKmsBackup,
|
loadProjectKmsBackup,
|
||||||
getProjectKmsKeys,
|
getProjectKmsKeys,
|
||||||
getProjectSlackConfig,
|
getProjectSlackConfig,
|
||||||
updateProjectSlackConfig
|
updateProjectSlackConfig,
|
||||||
|
requestProjectAccess,
|
||||||
|
searchProjects
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { Knex } from "knex";
|
import { Knex } from "knex";
|
||||||
|
|
||||||
import { ProjectType, TProjectKeys } from "@app/db/schemas";
|
import { ProjectType, SortDirection, TProjectKeys } from "@app/db/schemas";
|
||||||
import { TProjectPermission } from "@app/lib/types";
|
import { OrgServiceActor, TProjectPermission } from "@app/lib/types";
|
||||||
|
|
||||||
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
|
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
|
||||||
|
|
||||||
@@ -158,3 +158,23 @@ export type TUpdateProjectSlackConfig = {
|
|||||||
isSecretRequestNotificationEnabled: boolean;
|
isSecretRequestNotificationEnabled: boolean;
|
||||||
secretRequestChannels: string;
|
secretRequestChannels: string;
|
||||||
} & TProjectPermission;
|
} & 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",
|
ExternalImportSuccessful = "externalImportSuccessful.handlebars",
|
||||||
ExternalImportFailed = "externalImportFailed.handlebars",
|
ExternalImportFailed = "externalImportFailed.handlebars",
|
||||||
ExternalImportStarted = "externalImportStarted.handlebars",
|
ExternalImportStarted = "externalImportStarted.handlebars",
|
||||||
SecretRequestCompleted = "secretRequestCompleted.handlebars"
|
SecretRequestCompleted = "secretRequestCompleted.handlebars",
|
||||||
|
ProjectAccessRequest = "projectAccess.handlebars",
|
||||||
|
OrgAdminProjectDirectAccess = "orgAdminProjectGrantAccess.handlebars"
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SmtpHost {
|
export enum SmtpHost {
|
||||||
|
@@ -49,4 +49,4 @@
|
|||||||
{{emailFooter}}
|
{{emailFooter}}
|
||||||
</body>
|
</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>
|
<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>
|
</Note>
|
||||||
|
|
||||||
|
|
||||||
## Accessing the Organization Admin Console
|
## 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
|
### 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.
|
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.
|
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
|
## 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."
|
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.
|
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.
|
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
|
## 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
|
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.
|
customized depending on the intended use case.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Secrets Overview
|
## 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.
|
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.
|
- If users B and C fetch the secret D back, they both get the value E.
|
||||||
|
|
||||||
<Info>
|
<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>
|
</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:
|
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/additional-privileges",
|
||||||
"documentation/platform/access-controls/temporary-access",
|
"documentation/platform/access-controls/temporary-access",
|
||||||
"documentation/platform/access-controls/access-requests",
|
"documentation/platform/access-controls/access-requests",
|
||||||
|
"documentation/platform/access-controls/project-access-requests",
|
||||||
"documentation/platform/pr-workflows",
|
"documentation/platform/pr-workflows",
|
||||||
"documentation/platform/groups"
|
"documentation/platform/groups"
|
||||||
]
|
]
|
||||||
@@ -210,7 +211,10 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"group": "Gateway",
|
"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",
|
"documentation/platform/project-templates",
|
||||||
{
|
{
|
||||||
|
@@ -9,12 +9,12 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PageHeader = ({ title, description, children, className }: 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="flex w-full justify-between">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<h1 className="mr-4 text-3xl font-semibold capitalize text-white">{title}</h1>
|
<h1 className="mr-4 text-3xl font-semibold capitalize text-white">{title}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div>{children}</div>
|
<div className="flex items-center">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-gray-400">{description}</div>
|
<div className="mt-2 text-gray-400">{description}</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -3,6 +3,7 @@ export {
|
|||||||
useDeleteGroupFromWorkspace,
|
useDeleteGroupFromWorkspace,
|
||||||
useLeaveProject,
|
useLeaveProject,
|
||||||
useMigrateProjectToV3,
|
useMigrateProjectToV3,
|
||||||
|
useRequestProjectAccess,
|
||||||
useUpdateGroupWorkspaceRole
|
useUpdateGroupWorkspaceRole
|
||||||
} from "./mutations";
|
} from "./mutations";
|
||||||
export {
|
export {
|
||||||
@@ -36,6 +37,7 @@ export {
|
|||||||
useListWorkspaceSshCertificates,
|
useListWorkspaceSshCertificates,
|
||||||
useListWorkspaceSshCertificateTemplates,
|
useListWorkspaceSshCertificateTemplates,
|
||||||
useNameWorkspaceSecrets,
|
useNameWorkspaceSecrets,
|
||||||
|
useSearchProjects,
|
||||||
useToggleAutoCapitalization,
|
useToggleAutoCapitalization,
|
||||||
useUpdateIdentityWorkspaceRole,
|
useUpdateIdentityWorkspaceRole,
|
||||||
useUpdateProject,
|
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,
|
TGetUpgradeProjectStatusDTO,
|
||||||
TListProjectIdentitiesDTO,
|
TListProjectIdentitiesDTO,
|
||||||
ToggleAutoCapitalizationDTO,
|
ToggleAutoCapitalizationDTO,
|
||||||
|
TSearchProjectsDTO,
|
||||||
TUpdateWorkspaceIdentityRoleDTO,
|
TUpdateWorkspaceIdentityRoleDTO,
|
||||||
TUpdateWorkspaceUserRoleDTO,
|
TUpdateWorkspaceUserRoleDTO,
|
||||||
UpdateAuditLogsRetentionDTO,
|
UpdateAuditLogsRetentionDTO,
|
||||||
@@ -145,14 +146,31 @@ export const useGetWorkspaceById = (
|
|||||||
|
|
||||||
export const useGetUserWorkspaces = ({
|
export const useGetUserWorkspaces = ({
|
||||||
includeRoles,
|
includeRoles,
|
||||||
type = "all"
|
type = "all",
|
||||||
|
options = {}
|
||||||
}: {
|
}: {
|
||||||
includeRoles?: boolean;
|
includeRoles?: boolean;
|
||||||
type?: ProjectType | "all";
|
type?: ProjectType | "all";
|
||||||
|
options?: { enabled?: boolean };
|
||||||
} = {}) =>
|
} = {}) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: workspaceKeys.getAllUserWorkspace(type),
|
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) => {
|
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";
|
import type { CaStatus } from "../ca";
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ export const workspaceKeys = {
|
|||||||
...params
|
...params
|
||||||
}: TListProjectIdentitiesDTO) =>
|
}: TListProjectIdentitiesDTO) =>
|
||||||
[...workspaceKeys.getWorkspaceIdentityMemberships(workspaceId), params] as const,
|
[...workspaceKeys.getWorkspaceIdentityMemberships(workspaceId), params] as const,
|
||||||
|
searchWorkspace: (dto: TSearchProjectsDTO) => ["search-projects", dto] as const,
|
||||||
getWorkspaceGroupMemberships: (workspaceId: string) =>
|
getWorkspaceGroupMemberships: (workspaceId: string) =>
|
||||||
[{ workspaceId }, "workspace-groups"] as const,
|
[{ workspaceId }, "workspace-groups"] as const,
|
||||||
getWorkspaceCas: ({ projectSlug }: { projectSlug: string }) =>
|
getWorkspaceCas: ({ projectSlug }: { projectSlug: string }) =>
|
||||||
|
@@ -173,3 +173,12 @@ export type TListProjectIdentitiesDTO = {
|
|||||||
export enum ProjectIdentityOrderBy {
|
export enum ProjectIdentityOrderBy {
|
||||||
Name = "name"
|
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,
|
faPlug,
|
||||||
faSignOut,
|
faSignOut,
|
||||||
faUser,
|
faUser,
|
||||||
|
faUserCog,
|
||||||
faUsers
|
faUsers
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
@@ -385,6 +386,19 @@ export const MinimizedOrgSidebar = () => {
|
|||||||
Organization Settings
|
Organization Settings
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</Link>
|
</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>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
@@ -541,18 +555,6 @@ export const MinimizedOrgSidebar = () => {
|
|||||||
/>
|
/>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</a>
|
</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" />
|
<div className="mt-1 h-1 border-t border-mineshaft-600" />
|
||||||
<DropdownMenuItem onClick={logOutUser} icon={<FontAwesomeIcon icon={faSignOut} />}>
|
<DropdownMenuItem onClick={logOutUser} icon={<FontAwesomeIcon icon={faSignOut} />}>
|
||||||
Log Out
|
Log Out
|
||||||
|
@@ -91,7 +91,8 @@ export const AccessManagementPage = () => {
|
|||||||
<p className="mb-2 mt-1 text-sm text-bunker-300">
|
<p className="mb-2 mt-1 text-sm text-bunker-300">
|
||||||
We've developed an improved privilege management system to better serve your
|
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
|
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>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
colorSchema="primary"
|
colorSchema="primary"
|
||||||
|
@@ -1,62 +1,22 @@
|
|||||||
// REFACTOR(akhilmhdh): This file needs to be split into multiple components too complex
|
// 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 { Helmet } from "react-helmet";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { faFolderOpen, faStar } from "@fortawesome/free-regular-svg-icons";
|
import { faExclamationCircle, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||||
import {
|
|
||||||
faArrowDownAZ,
|
|
||||||
faArrowRight,
|
|
||||||
faArrowUpZA,
|
|
||||||
faBorderAll,
|
|
||||||
faExclamationCircle,
|
|
||||||
faList,
|
|
||||||
faMagnifyingGlass,
|
|
||||||
faPlus,
|
|
||||||
faSearch,
|
|
||||||
faStar as faSolidStar
|
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||||
import { createNotification } from "@app/components/notifications";
|
|
||||||
import { OrgPermissionCan } from "@app/components/permissions";
|
import { OrgPermissionCan } from "@app/components/permissions";
|
||||||
import { NewProjectModal } from "@app/components/projects";
|
import { NewProjectModal } from "@app/components/projects";
|
||||||
import {
|
import { Button, PageHeader } from "@app/components/v2";
|
||||||
Button,
|
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
|
||||||
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 { useFetchServerStatus } from "@app/hooks/api/serverDetails";
|
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 { ProjectType } from "@app/hooks/api/workspace/types";
|
||||||
import { usePopUp } from "@app/hooks/usePopUp";
|
import { usePopUp } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
enum ProjectsViewMode {
|
import { AllProjectView } from "./components/AllProjectView";
|
||||||
GRID = "grid",
|
import { MyProjectView } from "./components/MyProjectView";
|
||||||
LIST = "list"
|
import { ProjectListView } from "./components/ProjectListToggle";
|
||||||
}
|
|
||||||
|
|
||||||
enum ProjectOrderBy {
|
|
||||||
Name = "name"
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDescription = (type: ProjectType) => {
|
const formatDescription = (type: ProjectType) => {
|
||||||
if (type === ProjectType.SecretManager)
|
if (type === ProjectType.SecretManager)
|
||||||
@@ -76,295 +36,20 @@ type Props = {
|
|||||||
export const ProductOverviewPage = ({ type }: Props) => {
|
export const ProductOverviewPage = ({ type }: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const [projectListView, setProjectListView] = useState(ProjectListView.MyProjects);
|
||||||
|
|
||||||
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 { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
||||||
"addNewWs",
|
"addNewWs",
|
||||||
"upgradePlan"
|
"upgradePlan"
|
||||||
] as const);
|
] as const);
|
||||||
|
|
||||||
const [searchFilter, setSearchFilter] = useState("");
|
|
||||||
const { data: serverDetails } = useFetchServerStatus();
|
const { data: serverDetails } = useFetchServerStatus();
|
||||||
const [projectsViewMode, setProjectsViewMode] = useState<ProjectsViewMode>(
|
|
||||||
(localStorage.getItem("projectsViewMode") as ProjectsViewMode) || ProjectsViewMode.GRID
|
|
||||||
);
|
|
||||||
|
|
||||||
const { subscription } = useSubscription();
|
const { subscription } = useSubscription();
|
||||||
|
|
||||||
const isAddingProjectsAllowed = subscription?.workspaceLimit
|
const isAddingProjectsAllowed = subscription?.workspaceLimit
|
||||||
? subscription.workspacesUsed < subscription.workspaceLimit
|
? subscription.workspacesUsed < subscription.workspaceLimit
|
||||||
: true;
|
: 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 (
|
return (
|
||||||
<div className="mx-auto flex max-w-7xl flex-col justify-start bg-bunker-800">
|
<div className="mx-auto flex max-w-7xl flex-col justify-start bg-bunker-800">
|
||||||
<Helmet>
|
<Helmet>
|
||||||
@@ -395,63 +80,7 @@ export const ProductOverviewPage = ({ type }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mb-4 flex flex-col items-start justify-start">
|
<div className="mb-4 flex flex-col items-start justify-start">
|
||||||
<PageHeader title="Projects" description={formatDescription(type)} />
|
<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>
|
|
||||||
<OrgPermissionCan I={OrgPermissionActions.Create} an={OrgPermissionSubjects.Workspace}>
|
<OrgPermissionCan I={OrgPermissionActions.Create} an={OrgPermissionSubjects.Workspace}>
|
||||||
{(isAllowed) => (
|
{(isAllowed) => (
|
||||||
<Button
|
<Button
|
||||||
@@ -471,40 +100,14 @@ export const ProductOverviewPage = ({ type }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</OrgPermissionCan>
|
</OrgPermissionCan>
|
||||||
</div>
|
</PageHeader>
|
||||||
{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>
|
</div>
|
||||||
|
{projectListView === ProjectListView.MyProjects && (
|
||||||
|
<MyProjectView type={type} onListViewToggle={setProjectListView} />
|
||||||
|
)}
|
||||||
|
{projectListView === ProjectListView.AllProjects && (
|
||||||
|
<AllProjectView type={type} onListViewToggle={setProjectListView} />
|
||||||
|
)}
|
||||||
<NewProjectModal
|
<NewProjectModal
|
||||||
isOpen={popUp.addNewWs.isOpen}
|
isOpen={popUp.addNewWs.isOpen}
|
||||||
onOpenChange={(isOpen) => handlePopUpToggle("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 { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
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 { CreatableSelect } from "@app/components/v2/CreatableSelect";
|
||||||
import {
|
import {
|
||||||
OrgPermissionActions,
|
OrgPermissionActions,
|
||||||
@@ -48,7 +57,12 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { currentOrg } = useOrganization();
|
const { currentOrg } = useOrganization();
|
||||||
const { currentWorkspace } = useWorkspace();
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
const navigate = useNavigate({ from: "" });
|
||||||
const { permission } = useOrgPermission();
|
const { permission } = useOrgPermission();
|
||||||
|
const requesterEmail = useSearch({
|
||||||
|
strict: false,
|
||||||
|
select: (el) => el?.requesterEmail
|
||||||
|
});
|
||||||
|
|
||||||
const orgId = currentOrg?.id || "";
|
const orgId = currentOrg?.id || "";
|
||||||
const workspaceId = currentWorkspace?.id || "";
|
const workspaceId = currentWorkspace?.id || "";
|
||||||
@@ -62,6 +76,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
control,
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
|
setValue,
|
||||||
watch,
|
watch,
|
||||||
formState: { isSubmitting, errors }
|
formState: { isSubmitting, errors }
|
||||||
} = useForm<TAddMemberForm>({
|
} = useForm<TAddMemberForm>({
|
||||||
@@ -71,6 +86,12 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
|
|
||||||
const { mutateAsync: addMembersToProject } = useAddUsersToOrg();
|
const { mutateAsync: addMembersToProject } = useAddUsersToOrg();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (requesterEmail) {
|
||||||
|
handlePopUpToggle("addMember", true);
|
||||||
|
}
|
||||||
|
}, [requesterEmail]);
|
||||||
|
|
||||||
const onAddMembers = async ({ orgMemberships, projectRoleSlugs }: TAddMemberForm) => {
|
const onAddMembers = async ({ orgMemberships, projectRoleSlugs }: TAddMemberForm) => {
|
||||||
if (!currentWorkspace) return;
|
if (!currentWorkspace) return;
|
||||||
if (!currentOrg?.id) return;
|
if (!currentOrg?.id) return;
|
||||||
@@ -126,12 +147,13 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
reset();
|
reset();
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredOrgUsers = useMemo(() => {
|
const { append } = useFieldArray<TAddMemberForm>({ control, name: "orgMemberships" });
|
||||||
|
const projectInviteList = useMemo(() => {
|
||||||
const wsUserUsernames = new Map();
|
const wsUserUsernames = new Map();
|
||||||
members?.forEach((member) => {
|
members?.forEach((member) => {
|
||||||
wsUserUsernames.set(member.user.username, true);
|
wsUserUsernames.set(member.user.username, true);
|
||||||
});
|
});
|
||||||
return (orgUsers || [])
|
const list = (orgUsers || [])
|
||||||
.filter(({ user: u }) => !wsUserUsernames.has(u.username))
|
.filter(({ user: u }) => !wsUserUsernames.has(u.username))
|
||||||
.map(({ id, inviteEmail, user: { firstName, lastName, email } }) => ({
|
.map(({ id, inviteEmail, user: { firstName, lastName, email } }) => ({
|
||||||
value: id,
|
value: id,
|
||||||
@@ -140,13 +162,31 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
? `${firstName} ${lastName}`
|
? `${firstName} ${lastName}`
|
||||||
: firstName || lastName || email || inviteEmail
|
: 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]);
|
}, [orgUsers, members]);
|
||||||
|
|
||||||
const selectedOrgMemberships = watch("orgMemberships");
|
const selectedOrgMemberships = watch("orgMemberships");
|
||||||
const selectedRoleSlugs = watch("projectRoleSlugs");
|
const selectedRoleSlugs = watch("projectRoleSlugs");
|
||||||
|
|
||||||
const { append } = useFieldArray<TAddMemberForm>({ control, name: "orgMemberships" });
|
|
||||||
|
|
||||||
const canInviteNewMembers = permission.can(
|
const canInviteNewMembers = permission.can(
|
||||||
OrgPermissionActions.Create,
|
OrgPermissionActions.Create,
|
||||||
OrgPermissionSubjects.Member
|
OrgPermissionSubjects.Member
|
||||||
@@ -155,7 +195,13 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={popUp?.addMember?.isOpen}
|
isOpen={popUp?.addMember?.isOpen}
|
||||||
onOpenChange={(isOpen) => handlePopUpToggle("addMember", isOpen)}
|
onOpenChange={(isOpen) => {
|
||||||
|
if (!isOpen)
|
||||||
|
navigate({
|
||||||
|
search: (prev) => ({ ...prev, requesterEmail: "" })
|
||||||
|
});
|
||||||
|
handlePopUpToggle("addMember", isOpen);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ModalContent
|
<ModalContent
|
||||||
bodyClassName="overflow-visible"
|
bodyClassName="overflow-visible"
|
||||||
@@ -185,7 +231,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
noOptionsMessage={() => (
|
noOptionsMessage={() => (
|
||||||
<>
|
<>
|
||||||
<p>
|
<p>
|
||||||
{!filteredOrgUsers.length && (
|
{!projectInviteList.list.length && (
|
||||||
<p>All organization members are already assigned to this project.</p>
|
<p>All organization members are already assigned to this project.</p>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
@@ -221,7 +267,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
placeholder="Add one or more users..."
|
placeholder="Add one or more users..."
|
||||||
isMulti
|
isMulti
|
||||||
name="members"
|
name="members"
|
||||||
options={filteredOrgUsers}
|
options={projectInviteList.list}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
@@ -231,7 +277,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
placeholder="Add one or more users..."
|
placeholder="Add one or more users..."
|
||||||
isMulti
|
isMulti
|
||||||
name="members"
|
name="members"
|
||||||
options={filteredOrgUsers}
|
options={projectInviteList.list}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
@@ -239,7 +285,6 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="projectRoleSlugs"
|
name="projectRoleSlugs"
|
||||||
@@ -263,6 +308,19 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
</FormControl>
|
</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>
|
||||||
<div className="mt-8 flex items-center">
|
<div className="mt-8 flex items-center">
|
||||||
<Button
|
<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 { zodValidator } from "@tanstack/zod-adapter";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -7,7 +7,8 @@ import { ProjectAccessControlTabs } from "@app/types/project";
|
|||||||
import { AccessControlPage } from "./AccessControlPage";
|
import { AccessControlPage } from "./AccessControlPage";
|
||||||
|
|
||||||
const AccessControlPageQuerySchema = z.object({
|
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(
|
export const Route = createFileRoute(
|
||||||
@@ -15,6 +16,9 @@ export const Route = createFileRoute(
|
|||||||
)({
|
)({
|
||||||
component: AccessControlPage,
|
component: AccessControlPage,
|
||||||
validateSearch: zodValidator(AccessControlPageQuerySchema),
|
validateSearch: zodValidator(AccessControlPageQuerySchema),
|
||||||
|
search: {
|
||||||
|
middlewares: [stripSearchParams({ requesterEmail: "" })]
|
||||||
|
},
|
||||||
beforeLoad: ({ context, params }) => {
|
beforeLoad: ({ context, params }) => {
|
||||||
return {
|
return {
|
||||||
breadcrumbs: [
|
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 { zodValidator } from "@tanstack/zod-adapter";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -7,7 +7,8 @@ import { ProjectAccessControlTabs } from "@app/types/project";
|
|||||||
import { AccessControlPage } from "./AccessControlPage";
|
import { AccessControlPage } from "./AccessControlPage";
|
||||||
|
|
||||||
const AccessControlPageQuerySchema = z.object({
|
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(
|
export const Route = createFileRoute(
|
||||||
@@ -15,6 +16,9 @@ export const Route = createFileRoute(
|
|||||||
)({
|
)({
|
||||||
component: AccessControlPage,
|
component: AccessControlPage,
|
||||||
validateSearch: zodValidator(AccessControlPageQuerySchema),
|
validateSearch: zodValidator(AccessControlPageQuerySchema),
|
||||||
|
search: {
|
||||||
|
middlewares: [stripSearchParams({ requesterEmail: "" })]
|
||||||
|
},
|
||||||
beforeLoad: ({ context, params }) => {
|
beforeLoad: ({ context, params }) => {
|
||||||
return {
|
return {
|
||||||
breadcrumbs: [
|
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 { zodValidator } from "@tanstack/zod-adapter";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -7,7 +7,8 @@ import { ProjectAccessControlTabs } from "@app/types/project";
|
|||||||
import { AccessControlPage } from "./AccessControlPage";
|
import { AccessControlPage } from "./AccessControlPage";
|
||||||
|
|
||||||
const AccessControlPageQuerySchema = z.object({
|
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(
|
export const Route = createFileRoute(
|
||||||
@@ -15,6 +16,9 @@ export const Route = createFileRoute(
|
|||||||
)({
|
)({
|
||||||
component: AccessControlPage,
|
component: AccessControlPage,
|
||||||
validateSearch: zodValidator(AccessControlPageQuerySchema),
|
validateSearch: zodValidator(AccessControlPageQuerySchema),
|
||||||
|
search: {
|
||||||
|
middlewares: [stripSearchParams({ requesterEmail: "" })]
|
||||||
|
},
|
||||||
beforeLoad: ({ context, params }) => {
|
beforeLoad: ({ context, params }) => {
|
||||||
return {
|
return {
|
||||||
breadcrumbs: [
|
breadcrumbs: [
|
||||||
|