This commit is contained in:
Scott Wilson
2025-09-15 10:58:57 -07:00
parent 8fee3c3882
commit 1f4b50a6e0
12 changed files with 269 additions and 104 deletions

View File

@@ -1064,6 +1064,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
type: z.nativeEnum(ProjectType).optional(),
orderBy: z.nativeEnum(SearchProjectSortBy).optional().default(SearchProjectSortBy.NAME),
orderDirection: z.nativeEnum(SortDirection).optional().default(SortDirection.ASC),
projectIds: z.string().trim().array().optional(),
name: z
.string()
.trim()

View File

@@ -399,6 +399,7 @@ export const projectDALFactory = (db: TDbClient) => {
name?: string;
sortBy?: SearchProjectSortBy;
sortDir?: SortDirection;
projectIds?: string[];
}) => {
const { limit = 20, offset = 0, sortBy = SearchProjectSortBy.NAME, sortDir = SortDirection.ASC } = dto;
@@ -454,6 +455,11 @@ export const projectDALFactory = (db: TDbClient) => {
if (dto.name) {
void query.whereILike(`${TableName.Project}.name`, `%${dto.name}%`);
}
if (dto.projectIds?.length) {
void query.whereIn(`${TableName.Project}.id`, dto.projectIds);
}
const docs = await query;
return { docs, totalCount: Number(docs?.[0]?.count ?? 0) };

View File

@@ -1806,7 +1806,8 @@ export const projectServiceFactory = ({
limit,
type,
orderBy,
orderDirection
orderDirection,
projectIds
}: TSearchProjectsDTO) => {
// check user belong to org
await permissionService.getOrgPermission(
@@ -1822,6 +1823,7 @@ export const projectServiceFactory = ({
offset,
name,
type,
projectIds,
orgId: permission.orgId,
actor: permission.type,
actorId: permission.id,

View File

@@ -221,6 +221,7 @@ export type TSearchProjectsDTO = {
limit?: number;
offset?: number;
orderBy?: SearchProjectSortBy;
projectIds?: string[];
orderDirection?: SortDirection;
};

View File

@@ -0,0 +1,88 @@
import { useForm } from "react-hook-form";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, Modal, ModalClose, ModalContent } from "@app/components/v2";
import { useRequestProjectAccess } from "@app/hooks/api";
import { Workspace } from "@app/hooks/api/workspace/types";
type ContentProps = {
projectId: string;
onComplete: () => void;
};
const Content = ({ projectId, onComplete }: ContentProps) => {
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"
});
onComplete();
}
}
);
};
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>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
);
};
type RequestProjectAccessModalProps = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
project?: Workspace;
onComplete?: () => void;
};
export const RequestProjectAccessModal = ({
isOpen,
onOpenChange,
project,
onComplete
}: RequestProjectAccessModalProps) => {
if (!project) return null;
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
title="Confirm Access Request"
subTitle={`Requesting access to project ${project?.name}. You may include an optional note for project admins to review your request.`}
>
<Content
onComplete={() => {
onOpenChange(false);
if (onComplete) onComplete();
}}
projectId={project?.id}
/>
</ModalContent>
</Modal>
);
};

View File

@@ -1 +1,2 @@
export { NewProjectModal } from "./NewProjectModal";
export * from "./RequestProjectAccessModal";

View File

@@ -186,6 +186,7 @@ export type TSearchProjectsDTO = {
name?: string;
limit?: number;
offset?: number;
projectIds?: string[];
type?: ProjectType;
options?: { enabled?: boolean };
orderBy?: ProjectIdentityOrderBy;

View File

@@ -38,11 +38,7 @@ import { OrgPermissionSubjects, ProjectPermissionSub } from "@app/context";
import { OrgPermissionAppConnectionActions } from "@app/context/OrgPermissionContext/types";
import { ProjectPermissionAppConnectionActions } from "@app/context/ProjectPermissionContext/types";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { getUserTablePreference, PreferenceKey, setUserTablePreference } from "@app/helpers/userTablePreferences";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { TAppConnection, useListAppConnections } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
@@ -311,38 +307,46 @@ export const AppConnectionsTable = ({ projectId, projectType }: Props) => {
<FontAwesomeIcon icon={faFilter} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="thin-scrollbar max-h-[70vh] overflow-y-auto" align="end">
<DropdownMenuContent
sideOffset={2}
className="thin-scrollbar max-h-[70vh] overflow-y-auto"
align="end"
>
<DropdownMenuLabel>Filter by Apps</DropdownMenuLabel>
{appConnections.length ? (
[...new Set(appConnections.map(({ app }) => app))].map((app) => (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilters((prev) => ({
...prev,
apps: prev.apps.includes(app)
? prev.apps.filter((a) => a !== app)
: [...prev.apps, app]
}));
}}
key={app}
icon={
filters.apps.includes(app) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
<div className="flex items-center gap-2">
<img
alt={`${APP_CONNECTION_MAP[app].name} integration`}
src={`/images/integrations/${APP_CONNECTION_MAP[app].image}`}
className="h-4 w-4"
/>
<span>{APP_CONNECTION_MAP[app].name}</span>
</div>
</DropdownMenuItem>
))
[...new Set(appConnections.map(({ app }) => app))]
.sort((a, b) => {
return a.toLowerCase().localeCompare(b.toLowerCase());
})
.map((app) => (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilters((prev) => ({
...prev,
apps: prev.apps.includes(app)
? prev.apps.filter((a) => a !== app)
: [...prev.apps, app]
}));
}}
key={app}
icon={
filters.apps.includes(app) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
<div className="flex items-center gap-2">
<img
alt={`${APP_CONNECTION_MAP[app].name} integration`}
src={`/images/integrations/${APP_CONNECTION_MAP[app].image}`}
className="h-4 w-4"
/>
<span>{APP_CONNECTION_MAP[app].name}</span>
</div>
</DropdownMenuItem>
))
) : (
<DropdownMenuItem isDisabled>No Connections Configured</DropdownMenuItem>
)}

View File

@@ -1,5 +1,4 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import {
faArrowDownAZ,
faBorderAll,
@@ -16,6 +15,7 @@ import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { RequestProjectAccessModal } from "@app/components/projects/RequestProjectAccessModal";
import {
Badge,
Button,
@@ -24,12 +24,9 @@ import {
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
FormControl,
IconButton,
Input,
Lottie,
Modal,
ModalContent,
Pagination,
Skeleton,
Tooltip
@@ -43,11 +40,7 @@ import {
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import {
useOrgAdminAccessProject,
useRequestProjectAccess,
useSearchProjects
} from "@app/hooks/api";
import { useOrgAdminAccessProject, useSearchProjects } from "@app/hooks/api";
import { ProjectType, Workspace, WorkspaceEnv } from "@app/hooks/api/workspace/types";
import {
ProjectListToggle,
@@ -62,53 +55,6 @@ type Props = {
onProjectListViewChange: (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 = ({
onAddNewProject,
onUpgradePlan,
@@ -419,20 +365,11 @@ export const AllProjectView = ({
<div className="text-center font-light">No Projects Found</div>
</div>
)}
<Modal
<RequestProjectAccessModal
isOpen={popUp.requestAccessConfirmation.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("requestAccessConfirmation", isOpen)}
>
<ModalContent
title="Confirm Access Request"
subTitle={`Requesting access to project ${requestedWorkspaceDetails?.name}. You may include an optional note for project admins to review your request.`}
>
<RequestAccessModal
onPopUpToggle={() => handlePopUpToggle("requestAccessConfirmation")}
projectId={requestedWorkspaceDetails?.id}
/>
</ModalContent>
</Modal>
project={requestedWorkspaceDetails}
/>
</div>
);
};

View File

@@ -5,7 +5,17 @@ import { AxiosError } from "axios";
import { Button } from "@app/components/v2";
import { ProjectAccessError } from "./components";
export const ErrorPage = ({ error }: ErrorComponentProps) => {
if (
error instanceof AxiosError &&
error.status === 403 &&
error.response?.data?.error === "User not a part of the specified project"
) {
return <ProjectAccessError />;
}
return (
<div className="flex h-screen w-screen items-center justify-center bg-mineshaft-900">
<div className="flex max-w-3xl flex-col rounded-md border border-mineshaft-600 bg-mineshaft-800 p-8 text-center text-mineshaft-200">

View File

@@ -0,0 +1,113 @@
import { faHome } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { RequestProjectAccessModal } from "@app/components/projects";
import { AccessRestrictedBanner, Button } from "@app/components/v2";
import { OrgPermissionSubjects } from "@app/context";
import { OrgPermissionAdminConsoleAction } from "@app/context/OrgPermissionContext/types";
import { getProjectHomePage } from "@app/helpers/project";
import { usePopUp } from "@app/hooks";
import { useOrgAdminAccessProject, useSearchProjects } from "@app/hooks/api";
export const ProjectAccessError = () => {
const orgAdminAccessProject = useOrgAdminAccessProject();
const navigate = useNavigate();
const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp([
"requestAccessConfirmation"
] as const);
const { projectId } = useParams({
strict: false
});
const { data, isPending: isProjectLoading } = useSearchProjects({
projectIds: projectId ? [projectId] : [],
options: {
enabled: Boolean(projectId)
}
});
const [project] = data?.projects ?? [];
const handleAccessProject = async () => {
if (!project) return;
try {
await orgAdminAccessProject.mutateAsync({
projectId: project.id
});
await navigate({
to: getProjectHomePage(project.type, project.environments),
params: {
projectId: project.id
}
});
} catch {
createNotification({
text: "Failed to access project",
type: "error"
});
}
};
return (
<div className="flex h-full w-full items-center justify-center">
<AccessRestrictedBanner
body={
<>
You are not currently a member of this project. Request access to join project.
<div className="mt-4 flex w-full justify-center gap-2">
<Link to="/organization/projects">
<Button variant="outline_bg">
<FontAwesomeIcon icon={faHome} className="mr-2" />
Back To Home
</Button>
</Link>
<OrgPermissionCan
I={OrgPermissionAdminConsoleAction.AccessAllProjects}
an={OrgPermissionSubjects.AdminConsole}
>
{(isAllowed) =>
isAllowed ? (
<Button
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleAccessProject();
}}
disabled={orgAdminAccessProject.isPending}
isLoading={isProjectLoading || orgAdminAccessProject.isPending}
>
Join Project as Admin
</Button>
) : (
<Button
onClick={() => handlePopUpOpen("requestAccessConfirmation")}
isLoading={isProjectLoading}
>
Request Access to Project
</Button>
)
}
</OrgPermissionCan>
</div>
<RequestProjectAccessModal
isOpen={popUp.requestAccessConfirmation.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("requestAccessConfirmation", isOpen)}
project={project}
onComplete={() => {
navigate({
to: "/organization/projects"
});
}}
/>
</>
}
/>
</div>
);
};

View File

@@ -0,0 +1 @@
export * from "./ProjectAccessError";