Fix merge conflicts

This commit is contained in:
Tuan Dang
2025-04-10 12:39:09 -07:00
443 changed files with 17531 additions and 2266 deletions
@@ -1,7 +1,10 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import {
faArrowDown,
faArrowUp,
faEllipsis,
faFilter,
faMagnifyingGlass,
faServer
} from "@fortawesome/free-solid-svg-icons";
@@ -12,14 +15,19 @@ import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
FormControl,
IconButton,
Input,
Pagination,
Popover,
PopoverContent,
PopoverTrigger,
Select,
SelectItem,
Spinner,
@@ -30,11 +38,12 @@ import {
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { OrgPermissionIdentityActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetIdentityMembershipOrgs, useGetOrgRoles, useUpdateIdentity } from "@app/hooks/api";
import { useGetOrgRoles, useSearchIdentities, useUpdateIdentity } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { OrgIdentityOrderBy } from "@app/hooks/api/organization/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -68,22 +77,22 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
page,
setPerPage
} = usePagination<OrgIdentityOrderBy>(OrgIdentityOrderBy.Name);
const [filteredRoles, setFilteredRoles] = useState<string[]>([]);
const organizationId = currentOrg?.id || "";
const { mutateAsync: updateMutateAsync } = useUpdateIdentity();
const { data, isPending, isFetching } = useGetIdentityMembershipOrgs(
{
organizationId,
offset,
limit,
orderDirection,
orderBy,
search: debouncedSearch
},
{ placeholderData: (prevData) => prevData }
);
const { data, isPending, isFetching } = useSearchIdentities({
offset,
limit,
orderDirection,
orderBy,
search: {
name: debouncedSearch ? { $contains: debouncedSearch } : undefined,
role: filteredRoles?.length ? { $in: filteredRoles } : undefined
}
});
const { totalCount = 0 } = data ?? {};
useResetPageHelper({
@@ -91,6 +100,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
offset,
setPage
});
const filterForm = useForm<{ roles: string }>();
const { data: roles } = useGetOrgRoles(organizationId);
@@ -132,13 +142,78 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
return (
<div>
<Input
containerClassName="mb-4"
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search identities by name..."
/>
<div className="mb-4 flex items-center space-x-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search identities by name..."
/>
<div>
<Popover>
<PopoverTrigger>
<IconButton
ariaLabel="filter"
variant="outline_bg"
className={filteredRoles?.length ? "border-primary" : ""}
>
<Tooltip content="Advance Filter">
<FontAwesomeIcon icon={faFilter} />
</Tooltip>
</IconButton>
</PopoverTrigger>
<PopoverContent className="w-auto border border-mineshaft-600 bg-mineshaft-800 p-2 drop-shadow-2xl">
<div className="mb-4 border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
Advance Filter
</div>
<form
onSubmit={filterForm.handleSubmit((el) => {
setFilteredRoles(el.roles?.split(",")?.filter(Boolean) || []);
})}
>
<Controller
control={filterForm.control}
name="roles"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Roles"
helperText="Eg: admin,viewer"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex items-center space-x-2">
<Button
type="submit"
size="xs"
colorSchema="primary"
variant="outline_bg"
className="mt-4"
>
Apply Filter
</Button>
{Boolean(filteredRoles.length) && (
<Button
size="xs"
variant="link"
className="ml-4 mt-4"
onClick={() => {
filterForm.reset({ roles: "" });
setFilteredRoles([]);
}}
>
Clear
</Button>
)}
</div>
</form>
</PopoverContent>
</Popover>
</div>
</div>
<TableContainer>
<Table>
<THead>
@@ -190,7 +265,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
<TBody>
{isPending && <TableSkeleton columns={3} innerKey="org-identities" />}
{!isPending &&
data?.identityMemberships.map(({ identity: { id, name }, role, customRole }) => {
data?.identities?.map(({ identity: { id, name }, role, customRole }) => {
return (
<Tr
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
@@ -307,10 +382,10 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
{!isPending && data && data?.identityMemberships.length === 0 && (
{!isPending && data && data?.identities.length === 0 && (
<EmptyState
title={
debouncedSearch.trim().length > 0
debouncedSearch.trim().length > 0 || filteredRoles?.length > 0
? "No identities match search filter"
: "No identities have been created in this organization"
}
@@ -258,7 +258,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
</Th>
<Th className="w-1/3">
<div className="flex items-center">
Email
Username
<IconButton
variant="plain"
className={`ml-2 ${orderBy === OrgMembersOrderBy.Email ? "" : "opacity-30"}`}
@@ -478,7 +478,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
onClick={(e) => {
e.stopPropagation();
if (currentOrg?.scimEnabled) {
if (currentOrg?.scimEnabled && isActive) {
createNotification({
text: "You cannot manage users from Infisical when org-level auth is enforced for your organization",
type: "error"
@@ -16,6 +16,8 @@ import { DatabricksConnectionForm } from "./DatabricksConnectionForm";
import { GcpConnectionForm } from "./GcpConnectionForm";
import { GitHubConnectionForm } from "./GitHubConnectionForm";
import { HumanitecConnectionForm } from "./HumanitecConnectionForm";
import { MsSqlConnectionForm } from "./MsSqlConnectionForm";
import { PostgresConnectionForm } from "./PostgresConnectionForm";
type FormProps = {
onComplete: (appConnection: TAppConnection) => void;
@@ -31,7 +33,10 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => {
const { name: appName } = APP_CONNECTION_MAP[app];
const onSubmit = async (
formData: DiscriminativePick<TAppConnection, "method" | "name" | "app" | "credentials">
formData: DiscriminativePick<
TAppConnection,
"method" | "name" | "app" | "credentials" | "isPlatformManagedCredentials"
>
) => {
try {
const connection = await createAppConnection.mutateAsync(formData);
@@ -65,6 +70,10 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => {
return <DatabricksConnectionForm onSubmit={onSubmit} />;
case AppConnection.Humanitec:
return <HumanitecConnectionForm onSubmit={onSubmit} />;
case AppConnection.Postgres:
return <PostgresConnectionForm onSubmit={onSubmit} />;
case AppConnection.MsSql:
return <MsSqlConnectionForm onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled App ${app}`);
}
@@ -75,7 +84,10 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
const { name: appName } = APP_CONNECTION_MAP[appConnection.app];
const onSubmit = async (
formData: DiscriminativePick<TAppConnection, "method" | "name" | "app" | "credentials">
formData: DiscriminativePick<
TAppConnection,
"method" | "name" | "app" | "credentials" | "isPlatformManagedCredentials"
>
) => {
try {
const connection = await updateAppConnection.mutateAsync({
@@ -112,6 +124,10 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
return <DatabricksConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.Humanitec:
return <HumanitecConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.Postgres:
return <PostgresConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.MsSql:
return <MsSqlConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
default:
throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`);
}
@@ -22,7 +22,7 @@ import {
type Props = {
appConnection?: TAwsConnection;
onSubmit: (formData: FormData) => void;
onSubmit: (formData: FormData) => Promise<void>;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
@@ -25,7 +25,7 @@ import {
type Props = {
appConnection?: TDatabricksConnection;
onSubmit: (formData: FormData) => void;
onSubmit: (formData: FormData) => Promise<void>;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
@@ -28,7 +28,7 @@ import {
type Props = {
appConnection?: TGcpConnection;
onSubmit: (formData: FormData) => void;
onSubmit: (formData: FormData) => Promise<void>;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
@@ -32,7 +32,8 @@ export const GenericAppConnectionsFields = () => {
isOptional
>
<TextArea
className="h-20 !resize-none"
className="!resize-none"
rows={1}
placeholder="Connection description..."
{...register("description")}
/>
@@ -21,7 +21,7 @@ import {
type Props = {
appConnection?: THumanitecConnection;
onSubmit: (formData: FormData) => void;
onSubmit: (formData: FormData) => Promise<void>;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
@@ -30,7 +30,7 @@ const rootSchema = genericAppConnectionFieldsSchema.extend({
const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({
method: z.literal(HumanitecConnectionMethod.API_TOKEN),
method: z.literal(HumanitecConnectionMethod.ApiToken),
credentials: z.object({
apiToken: z.string().trim().min(1, "Service API Token required")
})
@@ -46,7 +46,7 @@ export const HumanitecConnectionForm = ({ appConnection, onSubmit }: Props) => {
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.Humanitec,
method: HumanitecConnectionMethod.API_TOKEN
method: HumanitecConnectionMethod.ApiToken
}
});
@@ -0,0 +1,158 @@
import { useState } from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, FormControl, ModalClose, Select, SelectItem } from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
MsSqlConnectionMethod,
TMsSqlConnection
} from "@app/hooks/api/appConnections/types/mssql-connection";
import { PlatformManagedConfirmationModal } from "@app/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/shared/PlatformManagedConfirmationModal";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
import {
BaseSqlUsernameAndPasswordConnectionSchema,
PlatformManagedNoticeBanner,
SqlConnectionFields
} from "./shared";
type Props = {
appConnection?: TMsSqlConnection;
onSubmit: (formData: FormData) => Promise<void>;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.MsSql),
isPlatformManagedCredentials: z.boolean().optional()
});
const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({
method: z.literal(MsSqlConnectionMethod.UsernameAndPassword),
credentials: BaseSqlUsernameAndPasswordConnectionSchema
})
]);
type FormData = z.infer<typeof formSchema>;
export const MsSqlConnectionForm = ({ appConnection, onSubmit }: Props) => {
const isUpdate = Boolean(appConnection);
const [showConfirmation, setShowConfirmation] = useState(false);
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.MsSql,
method: MsSqlConnectionMethod.UsernameAndPassword,
credentials: {
host: "",
port: 1433,
database: "default",
username: "",
password: "",
sslEnabled: true,
sslRejectUnauthorized: true,
sslCertificate: undefined
}
}
});
const {
handleSubmit,
control,
formState: { isSubmitting, isDirty }
} = form;
const isPlatformManagedCredentials = appConnection?.isPlatformManagedCredentials ?? false;
const confirmSubmit = async (formData: FormData) => {
if (formData.isPlatformManagedCredentials) {
setShowConfirmation(true);
return;
}
await onSubmit(formData);
};
return (
<FormProvider {...form}>
<form
onSubmit={(e) => {
setSelectedTabIndex(0);
handleSubmit(confirmSubmit)(e);
}}
>
{!isUpdate && <GenericAppConnectionsFields />}
<Controller
name="method"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText={`The method you would like to use to connect with ${
APP_CONNECTION_MAP[AppConnection.MsSql].name
}. This field cannot be changed after creation.`}
errorText={error?.message}
isError={Boolean(error?.message)}
label="Method"
>
<Select
isDisabled={isUpdate}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
dropdownContainerClassName="max-w-none"
>
{Object.values(MsSqlConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{getAppConnectionMethodDetails(method).name}{" "}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<SqlConnectionFields
isPlatformManagedCredentials={isPlatformManagedCredentials}
selectedTabIndex={selectedTabIndex}
setSelectedTabIndex={setSelectedTabIndex}
/>
{isPlatformManagedCredentials ? (
<PlatformManagedNoticeBanner />
) : (
<div className="mt-6 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Credentials" : "Connect to Database"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
)}
</form>
<PlatformManagedConfirmationModal
onConfirm={() => handleSubmit(onSubmit)()}
onOpenChange={setShowConfirmation}
isOpen={showConfirmation}
/>
</FormProvider>
);
};
@@ -0,0 +1,155 @@
import { useState } from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, FormControl, ModalClose, Select, SelectItem } from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { PostgresConnectionMethod, TPostgresConnection } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { PlatformManagedConfirmationModal } from "@app/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/shared/PlatformManagedConfirmationModal";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
import {
BaseSqlUsernameAndPasswordConnectionSchema,
PlatformManagedNoticeBanner,
SqlConnectionFields
} from "./shared";
type Props = {
appConnection?: TPostgresConnection;
onSubmit: (formData: FormData) => Promise<void>;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.Postgres),
isPlatformManagedCredentials: z.boolean().optional()
});
const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({
method: z.literal(PostgresConnectionMethod.UsernameAndPassword),
credentials: BaseSqlUsernameAndPasswordConnectionSchema
})
]);
type FormData = z.infer<typeof formSchema>;
export const PostgresConnectionForm = ({ appConnection, onSubmit }: Props) => {
const isUpdate = Boolean(appConnection);
const [showConfirmation, setShowConfirmation] = useState(false);
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.Postgres,
method: PostgresConnectionMethod.UsernameAndPassword,
credentials: {
host: "",
port: 5432,
database: "default",
username: "",
password: "",
sslEnabled: true,
sslRejectUnauthorized: true,
sslCertificate: undefined
}
}
});
const {
handleSubmit,
control,
formState: { isSubmitting, isDirty }
} = form;
const isPlatformManagedCredentials = appConnection?.isPlatformManagedCredentials ?? false;
const confirmSubmit = async (formData: FormData) => {
if (formData.isPlatformManagedCredentials) {
setShowConfirmation(true);
return;
}
await onSubmit(formData);
};
return (
<FormProvider {...form}>
<form
onSubmit={(e) => {
setSelectedTabIndex(0);
handleSubmit(confirmSubmit)(e);
}}
>
{!isUpdate && <GenericAppConnectionsFields />}
<Controller
name="method"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText={`The method you would like to use to connect with ${
APP_CONNECTION_MAP[AppConnection.Postgres].name
}. This field cannot be changed after creation.`}
errorText={error?.message}
isError={Boolean(error?.message)}
label="Method"
>
<Select
isDisabled={isUpdate}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
dropdownContainerClassName="max-w-none"
>
{Object.values(PostgresConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{getAppConnectionMethodDetails(method).name}{" "}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<SqlConnectionFields
isPlatformManagedCredentials={isPlatformManagedCredentials}
selectedTabIndex={selectedTabIndex}
setSelectedTabIndex={setSelectedTabIndex}
/>
{isPlatformManagedCredentials ? (
<PlatformManagedNoticeBanner />
) : (
<div className="mt-6 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Credentials" : "Connect to Database"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
)}
</form>
<PlatformManagedConfirmationModal
onConfirm={() => handleSubmit(onSubmit)()}
onOpenChange={setShowConfirmation}
isOpen={showConfirmation}
/>
</FormProvider>
);
};
@@ -0,0 +1,43 @@
import { Button, Modal, ModalClose, ModalContent } from "@app/components/v2";
import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
onConfirm: () => void;
};
export const PlatformManagedConfirmationModal = ({ isOpen, onOpenChange, onConfirm }: Props) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent className="max-w-2xl" title="Platform Managed Credentials">
<NoticeBannerV2 title="Are you sure you want have Infisical manage the credentials of this connection?">
<p className="my-1 text-sm text-mineshaft-300">
Once created, Infisical will update the password of this connection.
</p>
<p className="text-sm text-mineshaft-300">
You will not be able to access the updated password.
</p>
</NoticeBannerV2>
<div className="mt-4 flex gap-2">
<ModalClose asChild>
<Button
onClick={onConfirm}
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
>
Grant Infisical Ownership
</Button>
</ModalClose>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,9 @@
import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2";
export const PlatformManagedNoticeBanner = () => (
<NoticeBannerV2 title="Platform Managed Credentials">
<p className="text-sm text-bunker-300">
This App Connection&#39;s credentials are managed by Infisical and cannot be updated.
</p>
</NoticeBannerV2>
);
@@ -0,0 +1,239 @@
import { Dispatch, SetStateAction } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Tab } from "@headlessui/react";
import { FormControl, Input, SecretInput, Switch, TextArea, Tooltip } from "@app/components/v2";
type Props = {
isPlatformManagedCredentials: boolean;
selectedTabIndex: number;
setSelectedTabIndex: Dispatch<SetStateAction<number>>;
};
export const SqlConnectionFields = ({
isPlatformManagedCredentials,
setSelectedTabIndex,
selectedTabIndex
}: Props) => {
const { control, watch } = useFormContext();
const sslEnabled = watch("credentials.sslEnabled");
return (
<>
<Tab.Group selectedIndex={selectedTabIndex} onChange={setSelectedTabIndex}>
<Tab.List className="-pb-1 mb-6 w-full border-b-2 border-mineshaft-600">
<Tab
className={({ selected }) =>
`w-30 -mb-[0.14rem] px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${
selected ? "border-b-2 border-mineshaft-300 text-mineshaft-200" : "text-bunker-300"
}`
}
>
Configuration
</Tab>
<Tab
className={({ selected }) =>
`w-30 -mb-[0.14rem] px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${
selected ? "border-b-2 border-mineshaft-300 text-mineshaft-200" : "text-bunker-300"
}`
}
>
SSL ({sslEnabled ? "Enabled" : "Disabled"})
</Tab>
</Tab.List>
<Tab.Panels className="mb-4 rounded border border-mineshaft-600 bg-mineshaft-700/70 p-3 pb-0">
<Tab.Panel>
<div className="mt-[0.675rem] flex items-start gap-2">
<Controller
name="credentials.host"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="flex-1"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Host"
>
<Input {...field} isDisabled={isPlatformManagedCredentials} />
</FormControl>
)}
/>
<Controller
name="credentials.database"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="flex-1"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Database Name"
>
<Input {...field} isDisabled={isPlatformManagedCredentials} />
</FormControl>
)}
/>
<Controller
name="credentials.port"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="w-28"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Port"
>
<Input type="number" {...field} isDisabled={isPlatformManagedCredentials} />
</FormControl>
)}
/>
</div>
<div className="mb-[0.675rem] flex items-start gap-2">
<Controller
name="credentials.username"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Username"
className="flex-1"
>
<Input {...field} isDisabled={isPlatformManagedCredentials} />
</FormControl>
)}
/>
<Controller
name="credentials.password"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Password"
className="flex-1"
>
<SecretInput
containerClassName="text-gray-400 w-full group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
value={value}
onChange={(e) => onChange(e.target.value)}
isDisabled={isPlatformManagedCredentials}
/>
</FormControl>
)}
/>
</div>
</Tab.Panel>
<Tab.Panel>
<Controller
name="credentials.sslEnabled"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error?.message)} errorText={error?.message}>
<Switch
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
id="ssl-enabled"
thumbClassName="bg-mineshaft-800"
isChecked={value}
onCheckedChange={onChange}
isDisabled={isPlatformManagedCredentials}
>
Enable SSL
</Switch>
</FormControl>
)}
/>
<Controller
name="credentials.sslCertificate"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
className={sslEnabled ? "" : "opacity-50"}
label="SSL Certificate"
isOptional
>
<TextArea
className="h-[3.5rem] !resize-none"
{...field}
isDisabled={isPlatformManagedCredentials || !sslEnabled}
/>
</FormControl>
)}
/>
<Controller
name="credentials.sslRejectUnauthorized"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
className={sslEnabled ? "" : "opacity-50"}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Switch
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
id="ssl-reject-unauthorized"
thumbClassName="bg-mineshaft-800"
isChecked={sslEnabled ? value : false}
onCheckedChange={onChange}
isDisabled={isPlatformManagedCredentials || !sslEnabled}
>
<p className="w-[9.5rem]">
Reject Unauthorized
<Tooltip
className="max-w-md"
content={
<p>
If enabled, Infisical will only connect to the server if it has a valid,
trusted SSL certificate.
</p>
}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
</Tooltip>
</p>
</Switch>
</FormControl>
)}
/>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
{!isPlatformManagedCredentials && (
<Controller
name="isPlatformManagedCredentials"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error?.message)} errorText={error?.message}>
<Switch
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
id="platform-managed"
thumbClassName="bg-mineshaft-800"
isChecked={value}
onCheckedChange={onChange}
isDisabled={isPlatformManagedCredentials}
>
<p className="w-[13.6rem]">
Platform Managed Credentials
<Tooltip
className="max-w-md"
content={
<p>
If enabled, Infisical will manage the credentials of this App Connection by
updating the password on creation.
</p>
}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
</Tooltip>
</p>
</Switch>
</FormControl>
)}
/>
)}
</>
);
};
@@ -0,0 +1,3 @@
export * from "./PlatformManagedNoticeBanner";
export * from "./sql-connection-schemas";
export * from "./SqlConnectionFields";
@@ -0,0 +1,16 @@
import { z } from "zod";
export const BaseSqlUsernameAndPasswordConnectionSchema = z.object({
host: z.string().trim().min(1, "Host required"),
port: z.coerce.number().default(5432),
database: z.string().trim().min(1, "Database required").default("default"),
username: z.string().trim().min(1, "Username required"),
password: z.string().trim().min(1, "Password required"),
sslEnabled: z.boolean().default(true),
sslRejectUnauthorized: z.boolean().default(true),
sslCertificate: z
.string()
.trim()
.transform((value) => value || undefined)
.optional()
});
@@ -6,6 +6,7 @@ import {
faEdit,
faEllipsisV,
faInfoCircle,
faServer,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -14,6 +15,7 @@ import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Badge,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -42,7 +44,7 @@ export const AppConnectionRow = ({
onEditCredentials,
onEditDetails
}: Props) => {
const { id, name, method, app, description } = appConnection;
const { id, name, method, app, description, isPlatformManagedCredentials } = appConnection;
const [isIdCopied, setIsIdCopied] = useToggle(false);
@@ -100,70 +102,82 @@ export const AppConnectionRow = ({
</Td>
<Td>
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
onClick={() => handleCopyId()}
>
Copy Connection ID
</DropdownMenuItem>
<OrgPermissionCan
I={OrgPermissionAppConnectionActions.Edit}
a={OrgPermissionSubjects.AppConnections}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEdit} />}
onClick={() => onEditDetails(appConnection)}
>
Edit Details
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionAppConnectionActions.Edit}
a={OrgPermissionSubjects.AppConnections}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faAsterisk} />}
onClick={() => onEditCredentials(appConnection)}
>
Edit Credentials
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionAppConnectionActions.Delete}
a={OrgPermissionSubjects.AppConnections}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
onClick={() => onDelete(appConnection)}
>
Delete Connection
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
<div className="flex items-center justify-end gap-2">
{isPlatformManagedCredentials && (
<Tooltip side="left" content="This connection's credentials are managed by Infisical.">
<div>
<Badge className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap">
<FontAwesomeIcon icon={faServer} />
<span>Platform Managed Credentials</span>
</Badge>
</div>
</Tooltip>
)}
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
onClick={() => handleCopyId()}
>
Copy Connection ID
</DropdownMenuItem>
<OrgPermissionCan
I={OrgPermissionAppConnectionActions.Edit}
a={OrgPermissionSubjects.AppConnections}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEdit} />}
onClick={() => onEditDetails(appConnection)}
>
Edit Details
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionAppConnectionActions.Edit}
a={OrgPermissionSubjects.AppConnections}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faAsterisk} />}
onClick={() => onEditCredentials(appConnection)}
>
{isPlatformManagedCredentials ? "View" : "Edit"} Credentials
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionAppConnectionActions.Delete}
a={OrgPermissionSubjects.AppConnections}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
onClick={() => onDelete(appConnection)}
>
Delete Connection
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</div>
</Td>
</Tr>
);
@@ -1,5 +1,6 @@
import { createNotification } from "@app/components/notifications";
import { DeleteActionModal } from "@app/components/v2";
import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2";
import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
import { TAppConnection, useDeleteAppConnection } from "@app/hooks/api/appConnections";
@@ -46,6 +47,17 @@ export const DeleteAppConnectionModal = ({ isOpen, onOpenChange, appConnection }
title={`Are you sure want to delete ${name}?`}
deleteKey={name}
onDeleteApproved={handleDeleteAppConnection}
/>
>
{appConnection.isPlatformManagedCredentials && (
<NoticeBannerV2 className="mt-3" title="Platform Managed Credentials">
<p className="text-sm text-bunker-300">
This App Connection&#39;s credentials are managed by Infisical.
</p>
<p className="mt-3 text-sm text-bunker-300">
By deleting this connection you may lose permanent access to the associated resource.
</p>
</NoticeBannerV2>
)}
</DeleteActionModal>
);
};
@@ -1,62 +1,21 @@
// REFACTOR(akhilmhdh): This file needs to be split into multiple components too complex
import { ReactNode, useMemo, useState } from "react";
import { useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { faFolderOpen, faStar } from "@fortawesome/free-regular-svg-icons";
import {
faArrowDownAZ,
faArrowRight,
faArrowUpZA,
faBorderAll,
faExclamationCircle,
faList,
faMagnifyingGlass,
faPlus,
faSearch,
faStar as faSolidStar
} from "@fortawesome/free-solid-svg-icons";
import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { NewProjectModal } from "@app/components/projects";
import {
Button,
IconButton,
Input,
PageHeader,
Pagination,
Skeleton,
Tooltip
} from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useSubscription
} from "@app/context";
import { getProjectHomePage } from "@app/helpers/project";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetUserWorkspaces } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
// import { fetchUserWsKey } from "@app/hooks/api/keys/queries";
import { PageHeader } from "@app/components/v2";
import { useSubscription } from "@app/context";
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
import { Workspace } from "@app/hooks/api/types";
import { useUpdateUserProjectFavorites } from "@app/hooks/api/users/mutation";
import { useGetUserProjectFavorites } from "@app/hooks/api/users/queries";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { usePopUp } from "@app/hooks/usePopUp";
enum ProjectsViewMode {
GRID = "grid",
LIST = "list"
}
enum ProjectOrderBy {
Name = "name"
}
import { AllProjectView } from "./components/AllProjectView";
import { MyProjectView } from "./components/MyProjectView";
import { ProjectListToggle, ProjectListView } from "./components/ProjectListToggle";
const formatDescription = (type: ProjectType) => {
if (type === ProjectType.SecretManager)
@@ -76,295 +35,20 @@ type Props = {
export const ProductOverviewPage = ({ type }: Props) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { data: workspaces, isPending: isWorkspaceLoading } = useGetUserWorkspaces({ type });
const { currentOrg } = useOrganization();
const orgWorkspaces = workspaces || [];
const { data: projectFavorites, isPending: isProjectFavoritesLoading } =
useGetUserProjectFavorites(currentOrg?.id);
const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites();
const isProjectViewLoading = isWorkspaceLoading || isProjectFavoritesLoading;
const [projectListView, setProjectListView] = useState(ProjectListView.MyProjects);
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"addNewWs",
"upgradePlan"
] as const);
const [searchFilter, setSearchFilter] = useState("");
const { data: serverDetails } = useFetchServerStatus();
const [projectsViewMode, setProjectsViewMode] = useState<ProjectsViewMode>(
(localStorage.getItem("projectsViewMode") as ProjectsViewMode) || ProjectsViewMode.GRID
);
const { subscription } = useSubscription();
const isAddingProjectsAllowed = subscription?.workspaceLimit
? subscription.workspacesUsed < subscription.workspaceLimit
: true;
const isWorkspaceEmpty = !isProjectViewLoading && orgWorkspaces?.length === 0;
const {
setPage,
perPage,
setPerPage,
page,
offset,
limit,
toggleOrderDirection,
orderDirection
} = usePagination(ProjectOrderBy.Name, { initPerPage: 24 });
const filteredWorkspaces = useMemo(
() =>
orgWorkspaces
.filter((ws) => ws?.name?.toLowerCase().includes(searchFilter.toLowerCase()))
.sort((a, b) =>
orderDirection === OrderByDirection.ASC
? a.name.toLowerCase().localeCompare(b.name.toLowerCase())
: b.name.toLowerCase().localeCompare(a.name.toLowerCase())
),
[searchFilter, orderDirection, orgWorkspaces]
);
useResetPageHelper({
setPage,
offset,
totalCount: filteredWorkspaces.length
});
const { workspacesWithFaveProp } = useMemo(() => {
const workspacesWithFav = filteredWorkspaces
.map((w): Workspace & { isFavorite: boolean } => ({
...w,
isFavorite: Boolean(projectFavorites?.includes(w.id))
}))
.sort((a, b) => Number(b.isFavorite) - Number(a.isFavorite))
.slice(offset, limit * page);
return {
workspacesWithFaveProp: workspacesWithFav
};
}, [filteredWorkspaces, projectFavorites, offset, limit, page]);
const addProjectToFavorites = async (projectId: string) => {
try {
if (currentOrg?.id) {
await updateUserProjectFavorites({
orgId: currentOrg?.id,
projectFavorites: [...(projectFavorites || []), projectId]
});
}
} catch {
createNotification({
text: "Failed to add project to favorites.",
type: "error"
});
}
};
const removeProjectFromFavorites = async (projectId: string) => {
try {
if (currentOrg?.id) {
await updateUserProjectFavorites({
orgId: currentOrg?.id,
projectFavorites: [...(projectFavorites || []).filter((entry) => entry !== projectId)]
});
}
} catch {
createNotification({
text: "Failed to remove project from favorites.",
type: "error"
});
}
};
const renderProjectGridItem = (workspace: Workspace, isFavorite: boolean) => (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
<div
onClick={() => {
navigate({
to: getProjectHomePage(workspace),
params: {
projectId: workspace.id
}
});
}}
key={workspace.id}
className="flex h-40 min-w-72 cursor-pointer flex-col rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4"
>
<div className="flex flex-row justify-between">
<div className="mt-0 truncate text-lg text-mineshaft-100">{workspace.name}</div>
{isFavorite ? (
<FontAwesomeIcon
icon={faSolidStar}
className="text-sm text-yellow-600 hover:text-mineshaft-400"
onClick={(e) => {
e.stopPropagation();
removeProjectFromFavorites(workspace.id);
}}
/>
) : (
<FontAwesomeIcon
icon={faStar}
className="text-sm text-mineshaft-400 hover:text-mineshaft-300"
onClick={(e) => {
e.stopPropagation();
addProjectToFavorites(workspace.id);
}}
/>
)}
</div>
<div
className="mb-2.5 mt-1 grow text-sm text-mineshaft-300"
style={{
overflow: "hidden",
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: 2
}}
>
{workspace.description}
</div>
<div className="flex w-full flex-row items-end justify-between place-self-end">
{type === ProjectType.SecretManager && (
<div className="mt-0 text-xs text-mineshaft-400">
{workspace.environments?.length || 0} environments
</div>
)}
<button type="button">
<div className="group ml-auto w-max cursor-pointer rounded-full border border-mineshaft-600 bg-mineshaft-900 px-4 py-2 text-sm text-mineshaft-300 transition-all hover:border-primary-500/80 hover:bg-primary-800/20 hover:text-mineshaft-200">
Explore{" "}
<FontAwesomeIcon
icon={faArrowRight}
className="pl-1.5 pr-0.5 duration-200 hover:pl-2 hover:pr-0"
/>
</div>
</button>
</div>
</div>
);
const renderProjectListItem = (workspace: Workspace, isFavorite: boolean, index: number) => (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
<div
onClick={() => {
navigate({
to: getProjectHomePage(workspace),
params: {
projectId: workspace.id
}
});
}}
key={workspace.id}
className={`group grid h-14 min-w-72 cursor-pointer grid-cols-6 border-l border-r border-t border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${
index === 0 && "rounded-t-md"
}`}
>
<div className="flex items-center sm:col-span-3 lg:col-span-4">
<div className="truncate text-sm text-mineshaft-100">{workspace.name}</div>
</div>
<div className="flex items-center justify-end sm:col-span-3 lg:col-span-2">
<div className="text-center text-sm text-mineshaft-300">
{workspace.environments?.length || 0} environments
</div>
{isFavorite ? (
<FontAwesomeIcon
icon={faSolidStar}
className="ml-6 text-sm text-yellow-600 hover:text-mineshaft-400"
onClick={(e) => {
e.stopPropagation();
removeProjectFromFavorites(workspace.id);
}}
/>
) : (
<FontAwesomeIcon
icon={faStar}
className="ml-6 text-sm text-mineshaft-400 hover:text-mineshaft-300"
onClick={(e) => {
e.stopPropagation();
addProjectToFavorites(workspace.id);
}}
/>
)}
</div>
</div>
);
let projectsComponents: ReactNode;
if (filteredWorkspaces.length || isProjectViewLoading) {
switch (projectsViewMode) {
case ProjectsViewMode.GRID:
projectsComponents = (
<div className="mt-4 grid w-full grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{isProjectViewLoading &&
Array.apply(0, Array(3)).map((_x, i) => (
<div
key={`workspace-cards-loading-${i + 1}`}
className="flex h-40 min-w-72 flex-col justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4"
>
<div className="mt-0 text-lg text-mineshaft-100">
<Skeleton className="w-3/4 bg-mineshaft-600" />
</div>
<div className="mt-0 pb-6 text-sm text-mineshaft-300">
<Skeleton className="w-1/2 bg-mineshaft-600" />
</div>
<div className="flex justify-end">
<Skeleton className="w-1/2 bg-mineshaft-600" />
</div>
</div>
))}
{!isProjectViewLoading && (
<>
{workspacesWithFaveProp.map((workspace) =>
renderProjectGridItem(workspace, workspace.isFavorite)
)}
</>
)}
</div>
);
break;
case ProjectsViewMode.LIST:
default:
projectsComponents = (
<div className="mt-4 w-full rounded-md">
{isProjectViewLoading &&
Array.apply(0, Array(3)).map((_x, i) => (
<div
key={`workspace-cards-loading-${i + 1}`}
className={`group flex h-12 min-w-72 cursor-pointer flex-row items-center justify-between border border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${
i === 0 && "rounded-t-md"
} ${i === 2 && "rounded-b-md border-b"}`}
>
<Skeleton className="w-full bg-mineshaft-600" />
</div>
))}
{!isProjectViewLoading &&
workspacesWithFaveProp.map((workspace, ind) =>
renderProjectListItem(workspace, workspace.isFavorite, ind)
)}
</div>
);
break;
}
} else if (orgWorkspaces.length && searchFilter) {
projectsComponents = (
<div className="mt-4 w-full rounded-md border border-mineshaft-700 bg-mineshaft-800 px-4 py-6 text-base text-mineshaft-300">
<FontAwesomeIcon
icon={faSearch}
className="mb-4 mt-2 w-full text-center text-5xl text-mineshaft-400"
/>
<div className="text-center font-light">No projects match search...</div>
</div>
);
}
return (
<div className="mx-auto flex max-w-7xl flex-col justify-start bg-bunker-800">
<Helmet>
@@ -395,116 +79,30 @@ export const ProductOverviewPage = ({ type }: Props) => {
</div>
)}
<div className="mb-4 flex flex-col items-start justify-start">
<PageHeader title="Projects" description={formatDescription(type)} />
<div className="flex w-full flex-row">
<Input
className="h-[2.3rem] bg-mineshaft-800 text-sm placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
placeholder="Search by project name..."
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
/>
<div className="ml-2 flex rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
<Tooltip content="Toggle Sort Direction">
<IconButton
className="min-w-[2.4rem] border-none hover:bg-mineshaft-600"
ariaLabel={`Sort ${
orderDirection === OrderByDirection.ASC ? "descending" : "ascending"
}`}
variant="plain"
size="xs"
colorSchema="secondary"
onClick={toggleOrderDirection}
>
<FontAwesomeIcon
icon={orderDirection === OrderByDirection.ASC ? faArrowDownAZ : faArrowUpZA}
/>
</IconButton>
</Tooltip>
</div>
<div className="ml-2 flex rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
<IconButton
variant="outline_bg"
onClick={() => {
localStorage.setItem("projectsViewMode", ProjectsViewMode.GRID);
setProjectsViewMode(ProjectsViewMode.GRID);
}}
ariaLabel="grid"
size="xs"
className={`${
projectsViewMode === ProjectsViewMode.GRID ? "bg-mineshaft-500" : "bg-transparent"
} min-w-[2.4rem] border-none hover:bg-mineshaft-600`}
>
<FontAwesomeIcon icon={faBorderAll} />
</IconButton>
<IconButton
variant="outline_bg"
onClick={() => {
localStorage.setItem("projectsViewMode", ProjectsViewMode.LIST);
setProjectsViewMode(ProjectsViewMode.LIST);
}}
ariaLabel="list"
size="xs"
className={`${
projectsViewMode === ProjectsViewMode.LIST ? "bg-mineshaft-500" : "bg-transparent"
} min-w-[2.4rem] border-none hover:bg-mineshaft-600`}
>
<FontAwesomeIcon icon={faList} />
</IconButton>
</div>
<OrgPermissionCan I={OrgPermissionActions.Create} an={OrgPermissionSubjects.Workspace}>
{(isAllowed) => (
<Button
isDisabled={!isAllowed}
colorSchema="primary"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
if (isAddingProjectsAllowed) {
handlePopUpOpen("addNewWs");
} else {
handlePopUpOpen("upgradePlan");
}
}}
className="ml-2"
>
Add New Project
</Button>
)}
</OrgPermissionCan>
</div>
{projectsComponents}
{!isProjectViewLoading && Boolean(filteredWorkspaces.length) && (
<Pagination
className={
projectsViewMode === ProjectsViewMode.GRID
? "col-span-full !justify-start border-transparent bg-transparent pl-2"
: "rounded-b-md border border-mineshaft-600"
}
perPage={perPage}
perPageList={[12, 24, 48, 96]}
count={filteredWorkspaces.length}
page={page}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{isWorkspaceEmpty && (
<div className="mt-4 w-full rounded-md border border-mineshaft-700 bg-mineshaft-800 px-4 py-6 text-base text-mineshaft-300">
<FontAwesomeIcon
icon={faFolderOpen}
className="mb-4 mt-2 w-full text-center text-5xl text-mineshaft-400"
/>
<div className="text-center font-light">
You are not part of any projects in this organization yet. When you are, they will
appear here.
<PageHeader
title={
<div className="flex items-center gap-4">
<ProjectListToggle value={projectListView} onChange={setProjectListView} />
</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>
)}
}
description={formatDescription(type)}
/>
</div>
{projectListView === ProjectListView.MyProjects ? (
<MyProjectView
type={type}
onAddNewProject={() => handlePopUpOpen("addNewWs")}
onUpgradePlan={() => handlePopUpOpen("upgradePlan")}
isAddingProjectsAllowed={isAddingProjectsAllowed}
/>
) : (
<AllProjectView
type={type}
onAddNewProject={() => handlePopUpOpen("addNewWs")}
onUpgradePlan={() => handlePopUpOpen("upgradePlan")}
isAddingProjectsAllowed={isAddingProjectsAllowed}
/>
)}
<NewProjectModal
isOpen={popUp.addNewWs.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addNewWs", isOpen)}
@@ -0,0 +1,305 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import {
faArrowDownAZ,
faBorderAll,
faCheck,
faFolderOpen,
faList,
faMagnifyingGlass,
faPlus
} 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 { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
FormControl,
IconButton,
Input,
Modal,
ModalContent,
Pagination,
Skeleton,
Tooltip
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
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";
type Props = {
type: ProjectType;
onAddNewProject: () => void;
onUpgradePlan: () => void;
isAddingProjectsAllowed: boolean;
};
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,
onAddNewProject,
onUpgradePlan,
isAddingProjectsAllowed
}: 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">
<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="w-full"
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>
<OrgPermissionCan I={OrgPermissionActions.Create} an={OrgPermissionSubjects.Workspace}>
{(isAllowed) => (
<Button
isDisabled={!isAllowed}
colorSchema="primary"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
if (isAddingProjectsAllowed) {
onAddNewProject();
} else {
onUpgradePlan();
}
}}
className="ml-2"
>
Add New Project
</Button>
)}
</OrgPermissionCan>
</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 an optional note for project admins to review your request.`}
>
<RequestAccessModal
onPopUpToggle={() => handlePopUpToggle("requestAccessConfirmation")}
projectId={requestedWorkspaceDetails?.id}
/>
</ModalContent>
</Modal>
</div>
);
};
@@ -0,0 +1,439 @@
import { ReactNode, useMemo, useState } from "react";
import { faFolderOpen, faStar } from "@fortawesome/free-regular-svg-icons";
import {
faArrowDownAZ,
faArrowRight,
faArrowUpZA,
faBorderAll,
faList,
faMagnifyingGlass,
faPlus,
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 { OrgPermissionCan } from "@app/components/permissions";
import { Button, IconButton, Input, Pagination, Skeleton, Tooltip } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, 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";
type Props = {
type: ProjectType;
onAddNewProject: () => void;
onUpgradePlan: () => void;
isAddingProjectsAllowed: boolean;
};
enum ProjectOrderBy {
Name = "name"
}
enum ProjectsViewMode {
GRID = "grid",
LIST = "list"
}
export const MyProjectView = ({
type,
onAddNewProject,
onUpgradePlan,
isAddingProjectsAllowed
}: 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">
<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="w-full"
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}>
{(isAllowed) => (
<Button
isDisabled={!isAllowed}
colorSchema="primary"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
if (isAddingProjectsAllowed) {
onAddNewProject();
} else {
onUpgradePlan();
}
}}
className="ml-2"
>
Add New Project
</Button>
)}
</OrgPermissionCan>
</div>
{projectsComponents}
{!isProjectViewLoading && Boolean(filteredWorkspaces.length) && (
<Pagination
className={
projectsViewMode === ProjectsViewMode.GRID
? "col-span-full !justify-start border-transparent bg-transparent pl-2"
: "rounded-b-md border border-mineshaft-600"
}
perPage={perPage}
perPageList={[12, 24, 48, 96]}
count={filteredWorkspaces.length}
page={page}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{isWorkspaceEmpty && (
<div className="mt-4 w-full rounded-md border border-mineshaft-700 bg-mineshaft-800 px-4 py-6 text-base text-mineshaft-300">
<FontAwesomeIcon
icon={faFolderOpen}
className="mb-4 mt-2 w-full text-center text-5xl text-mineshaft-400"
/>
<div className="text-center font-light">
You are not part of any projects in this organization yet. When you are, they will
appear here.
</div>
<div className="mt-0.5 text-center font-light">
Create a new project, or ask other organization members to give you necessary
permissions.
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,40 @@
import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
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) => {
const getDisplayText = (listView: ProjectListView) => {
return listView === ProjectListView.MyProjects ? "My Projects" : "All Projects";
};
return (
<div className="group relative flex cursor-pointer items-center gap-2">
<h1 className="text-3xl font-semibold transition-colors group-hover:text-gray-500">
{getDisplayText(value)}
</h1>
<Select
value={value}
onValueChange={onChange}
className="absolute left-0 top-0 h-full w-full cursor-pointer opacity-0"
>
<SelectItem value={ProjectListView.MyProjects}>My Projects</SelectItem>
<SelectItem value={ProjectListView.AllProjects}>All Projects</SelectItem>
</Select>
<FontAwesomeIcon
icon={faChevronDown}
className="text-lg transition-colors group-hover:text-gray-500"
/>
</div>
);
};