Merge remote-tracking branch 'origin/main' into feat/add-render-app-connection-and-secret-sync

This commit is contained in:
Sheen Capadngan
2025-06-19 03:14:23 +08:00
281 changed files with 6700 additions and 1520 deletions
@@ -15,7 +15,8 @@ import {
IconButton,
Input,
Modal,
ModalContent
ModalContent,
Switch
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { findOrgMembershipRole } from "@app/helpers/roles";
@@ -27,6 +28,7 @@ const schema = z
.object({
name: z.string().min(1, "Required"),
role: z.object({ slug: z.string(), name: z.string() }),
hasDeleteProtection: z.boolean(),
metadata: z
.object({
key: z.string().trim().min(1),
@@ -64,7 +66,8 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
name: ""
name: "",
hasDeleteProtection: false
}
});
@@ -78,6 +81,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
identityId: string;
name: string;
role: string;
hasDeleteProtection: boolean;
metadata?: { key: string; value: string }[];
customRole: {
name: string;
@@ -91,22 +95,25 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
reset({
name: identity.name,
role: identity.customRole ?? findOrgMembershipRole(roles, identity.role),
hasDeleteProtection: identity.hasDeleteProtection,
metadata: identity.metadata
});
} else {
reset({
name: "",
role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole)
role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole),
hasDeleteProtection: false
});
}
}, [popUp?.identity?.data, roles]);
const onFormSubmit = async ({ name, role, metadata }: FormData) => {
const onFormSubmit = async ({ name, role, metadata, hasDeleteProtection }: FormData) => {
try {
const identity = popUp?.identity?.data as {
identityId: string;
name: string;
role: string;
hasDeleteProtection: boolean;
};
if (identity) {
@@ -116,6 +123,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
identityId: identity.identityId,
name,
role: role.slug || undefined,
hasDeleteProtection,
organizationId: orgId,
metadata
});
@@ -127,6 +135,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
const { id: createdId } = await createMutateAsync({
name,
role: role.slug || undefined,
hasDeleteProtection,
organizationId: orgId,
metadata
});
@@ -215,6 +224,24 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
</FormControl>
)}
/>
<Controller
control={control}
name="hasDeleteProtection"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl errorText={error?.message} isError={Boolean(error)}>
<Switch
className="ml-0 mr-2 bg-mineshaft-400/80 shadow-inner data-[state=checked]:bg-green/80"
containerClassName="flex-row-reverse w-fit"
id="delete-protection-enabled"
thumbClassName="bg-mineshaft-800"
onCheckedChange={onChange}
isChecked={value}
>
<p>Delete Protection {value ? "Enabled" : "Disabled"}</p>
</Switch>
</FormControl>
)}
/>
<div>
<FormLabel label="Metadata" />
</div>
@@ -329,7 +329,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<DropdownMenuContent align="start" className="mt-3 p-1">
<OrgPermissionCan
I={OrgPermissionIdentityActions.Edit}
a={OrgPermissionSubjects.Identity}
@@ -18,6 +18,7 @@ import { AzureDevOpsConnectionForm } from "./AzureDevOpsConnectionForm";
import { AzureKeyVaultConnectionForm } from "./AzureKeyVaultConnectionForm";
import { CamundaConnectionForm } from "./CamundaConnectionForm";
import { DatabricksConnectionForm } from "./DatabricksConnectionForm";
import { FlyioConnectionForm } from "./FlyioConnectionForm";
import { GcpConnectionForm } from "./GcpConnectionForm";
import { GitHubConnectionForm } from "./GitHubConnectionForm";
import { GitHubRadarConnectionForm } from "./GitHubRadarConnectionForm";
@@ -122,6 +123,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => {
return <OnePassConnectionForm onSubmit={onSubmit} />;
case AppConnection.Render:
return <RenderConnectionForm onSubmit={onSubmit} />;
case AppConnection.Flyio:
return <FlyioConnectionForm onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled App ${app}`);
}
@@ -208,6 +211,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
return <OnePassConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.Render:
return <RenderConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.Flyio:
return <FlyioConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
default:
throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`);
}
@@ -0,0 +1,136 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Button,
FormControl,
ModalClose,
SecretInput,
Select,
SelectItem
} from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { FlyioConnectionMethod, TFlyioConnection } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
type Props = {
appConnection?: TFlyioConnection;
onSubmit: (formData: FormData) => void;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.Flyio)
});
const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({
method: z.literal(FlyioConnectionMethod.AccessToken),
credentials: z.object({
accessToken: z
.string()
.trim()
.min(1, "Access Token required")
.startsWith("FlyV1", "Token must start with 'FlyV1'")
})
})
]);
type FormData = z.infer<typeof formSchema>;
export const FlyioConnectionForm = ({ appConnection, onSubmit }: Props) => {
const isUpdate = Boolean(appConnection);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.Flyio,
method: FlyioConnectionMethod.AccessToken
}
});
const {
handleSubmit,
control,
formState: { isSubmitting, isDirty }
} = form;
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
{!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.Flyio].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(FlyioConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{getAppConnectionMethodDetails(method).name}{" "}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<Controller
name="credentials.accessToken"
control={control}
shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Access Token"
>
<SecretInput
containerClassName="text-gray-400 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)}
/>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Credentials" : "Connect to Fly.io"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -47,13 +47,19 @@ export const IdentityDetailsSection = ({ identityId, handlePopUpOpen }: Props) =
<DropdownMenuTrigger asChild>
<Button
size="xs"
rightIcon={<FontAwesomeIcon className="ml-1" icon={faChevronDown} />}
rightIcon={
<FontAwesomeIcon
className="ml-1 transition-transform duration-200 group-data-[state=open]:rotate-180"
icon={faChevronDown}
/>
}
colorSchema="secondary"
className="group select-none"
>
Options
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[120px]" align="end">
<DropdownMenuContent className="mt-3 min-w-[120px]" align="end">
<OrgPermissionCan
I={OrgPermissionIdentityActions.Edit}
a={OrgPermissionSubjects.Identity}
@@ -68,6 +74,7 @@ export const IdentityDetailsSection = ({ identityId, handlePopUpOpen }: Props) =
handlePopUpOpen("identity", {
identityId,
name: data.identity.name,
hasDeleteProtection: data.identity.hasDeleteProtection,
role: data.role,
customRole: data.customRole
});
@@ -131,6 +138,12 @@ export const IdentityDetailsSection = ({ identityId, handlePopUpOpen }: Props) =
<p className="text-sm font-semibold text-mineshaft-300">Name</p>
<p className="text-sm text-mineshaft-300">{data.identity.name}</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Delete Protection</p>
<p className="text-sm text-mineshaft-300">
{data.identity.hasDeleteProtection ? "On" : "Off"}
</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Organization Role</p>
<p className="text-sm text-mineshaft-300">{data.role}</p>
@@ -35,7 +35,7 @@ export const ViewIdentityContentWrapper = ({ children, onDelete, onEdit }: Props
Options
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[120px]" align="end">
<DropdownMenuContent className="mt-3 min-w-[120px]" align="end">
<OrgPermissionCan
I={OrgPermissionIdentityActions.Edit}
a={OrgPermissionSubjects.Identity}
@@ -72,7 +72,7 @@ export const GroupsSection = () => {
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Groups}>
{(isAllowed) => (
<Button
colorSchema="primary"
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handleAddGroupModal()}
@@ -2,10 +2,11 @@ import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faEllipsisV,
faMagnifyingGlass,
faSearch,
faTrash,
faUsers
faUsers,
faUsersSlash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
@@ -13,6 +14,10 @@ import { format } from "date-fns";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
@@ -184,33 +189,42 @@ export const GroupTable = ({ handlePopUpOpen }: Props) => {
</Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
<Td className="flex justify-end">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Groups}
>
{(isAllowed) => (
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content="Remove">
<IconButton
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteGroup", {
id,
name
});
}}
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
</div>
)}
</ProjectPermissionCan>
<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 sideOffset={2} align="end">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Groups}
>
{(isAllowed) => (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faUsersSlash} />}
isDisabled={!isAllowed}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteGroup", {
id,
name
});
}}
>
Remove Group From Project
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</Td>
</Tr>
);
@@ -3,12 +3,13 @@ import {
faArrowDown,
faArrowUp,
faArrowUpRightFromSquare,
faBookOpen,
faCircleXmark,
faClock,
faEllipsisV,
faMagnifyingGlass,
faPlus,
faServer,
faXmark
faServer
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
@@ -21,6 +22,10 @@ import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
HoverCard,
HoverCardContent,
@@ -163,20 +168,21 @@ export const IdentityTab = withProjectPermission(
>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
<div className="flex w-full justify-end pr-4">
<div className="flex items-center gap-1">
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
<a
href="https://infisical.com/docs/documentation/platform/identities/overview"
target="_blank"
rel="noopener noreferrer"
href="https://infisical.com/docs/documentation/platform/identities/overview"
>
<span className="flex w-max cursor-pointer items-center rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
Documentation{" "}
<div className="ml-1 mt-[0.16rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] ml-1 text-xs"
className="mb-[0.07rem] ml-1.5 text-[10px]"
/>
</span>
</div>
</a>
</div>
<ProjectPermissionCan
@@ -185,7 +191,7 @@ export const IdentityTab = withProjectPermission(
>
{(isAllowed) => (
<Button
colorSchema="primary"
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("identity")}
@@ -231,7 +237,7 @@ export const IdentityTab = withProjectPermission(
</Th>
<Th className="w-1/3">Role</Th>
<Th>Added on</Th>
<Th className="w-16">{isFetching ? <Spinner size="xs" /> : null}</Th>
<Th className="w-5">{isFetching ? <Spinner size="xs" /> : null}</Th>
</Tr>
</THead>
<TBody>
@@ -372,37 +378,46 @@ export const IdentityTab = withProjectPermission(
</div>
</Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
<Td className="flex justify-end space-x-2 opacity-0 duration-300 group-hover:opacity-100">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Identity, {
identityId: id
})}
>
{(isAllowed) => (
<IconButton
onClick={(evt) => {
evt.stopPropagation();
evt.preventDefault();
handlePopUpOpen("deleteIdentity", {
identityId: id,
name
});
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
)}
</ProjectPermissionCan>
<IconButton ariaLabel="more-icon" variant="plain">
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
<Td className="flex justify-end space-x-2">
<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 sideOffset={2} align="end">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Identity, {
identityId: id
})}
>
{(isAllowed) => (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faCircleXmark} />}
isDisabled={!isAllowed}
onClick={(evt) => {
evt.stopPropagation();
evt.preventDefault();
handlePopUpOpen("deleteIdentity", {
identityId: id,
name
});
}}
>
Remove Identity From Project
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</Td>
</Tr>
);
@@ -56,12 +56,12 @@ export const MembersSection = () => {
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<div className="mb-4 flex items-center justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Users</p>
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Member}>
{(isAllowed) => (
<Button
colorSchema="primary"
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("addMember")}
@@ -9,8 +9,8 @@ import {
faFilter,
faMagnifyingGlass,
faSearch,
faTrash,
faUsers
faUsers,
faUserXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
@@ -416,32 +416,40 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
</Td>
<Td>
{userId !== u?.id && (
<div className="flex items-center space-x-2 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
colorSchema="danger"
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={userId === u?.id || !isAllowed}
onClick={(evt) => {
evt.preventDefault();
evt.stopPropagation();
handlePopUpOpen("removeMember", { username: u.username });
}}
>
<FontAwesomeIcon icon={faTrash} />
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
)}
</ProjectPermissionCan>
<IconButton ariaLabel="more-icon" variant="plain">
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faUserXmark} />}
isDisabled={!isAllowed}
onClick={(evt) => {
evt.preventDefault();
evt.stopPropagation();
handlePopUpOpen("removeMember", { username: u.username });
}}
>
Remove User From Project
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
)}
</Td>
</Tr>
@@ -10,10 +10,10 @@ export const ProjectRoleListTab = withProjectPermission(
return (
<motion.div
key="role-list"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: -30 }}
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
exit={{ opacity: 0, translateX: 30 }}
>
<ProjectRoleList />
</motion.div>
@@ -1,4 +1,16 @@
import { faEllipsis, faPlus } from "@fortawesome/free-solid-svg-icons";
import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faCopy,
faEdit,
faEllipsisV,
faEye,
faMagnifyingGlass,
faPlus,
faSearch,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
@@ -12,6 +24,10 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
@@ -19,15 +35,27 @@ import {
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { useDeleteProjectRole, useGetProjectRoles } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { ProjectMembershipRole, TProjectRole } from "@app/hooks/api/roles/types";
import { DuplicateProjectRoleModal } from "@app/pages/project/RoleDetailsBySlugPage/components/DuplicateProjectRoleModal";
import { RoleModal } from "@app/pages/project/RoleDetailsBySlugPage/components/RoleModal";
enum RolesOrderBy {
Name = "name",
Slug = "slug"
}
export const ProjectRoleList = () => {
const navigate = useNavigate();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
@@ -57,6 +85,75 @@ export const ProjectRoleList = () => {
}
};
const {
orderDirection,
toggleOrderDirection,
orderBy,
setOrderDirection,
setOrderBy,
search,
setSearch,
page,
perPage,
setPerPage,
setPage,
offset
} = usePagination<RolesOrderBy>(RolesOrderBy.Name, {
initPerPage: getUserTablePreference("projectRolesTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("projectRolesTable", PreferenceKey.PerPage, newPerPage);
};
const filteredRoles = useMemo(
() =>
roles
?.filter((role) => {
const { slug, name } = role;
const searchValue = search.trim().toLowerCase();
return (
name.toLowerCase().includes(searchValue) || slug.toLowerCase().includes(searchValue)
);
})
.sort((a, b) => {
const [roleOne, roleTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case RolesOrderBy.Slug:
return roleOne.slug.toLowerCase().localeCompare(roleTwo.slug.toLowerCase());
case RolesOrderBy.Name:
default:
return roleOne.name.toLowerCase().localeCompare(roleTwo.name.toLowerCase());
}
}) ?? [],
[roles, orderDirection, search, orderBy]
);
useResetPageHelper({
totalCount: filteredRoles.length,
offset,
setPage
});
const handleSort = (column: RolesOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
const getClassName = (col: RolesOrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: RolesOrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
@@ -64,7 +161,7 @@ export const ProjectRoleList = () => {
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Role}>
{(isAllowed) => (
<Button
colorSchema="primary"
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("role")}
@@ -75,18 +172,50 @@ export const ProjectRoleList = () => {
)}
</ProjectPermissionCan>
</div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search project roles..."
className="flex-1"
containerClassName="mb-4"
/>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Slug</Th>
<Th>
<div className="flex items-center">
Name
<IconButton
variant="plain"
className={getClassName(RolesOrderBy.Name)}
ariaLabel="sort"
onClick={() => handleSort(RolesOrderBy.Name)}
>
<FontAwesomeIcon icon={getColSortIcon(RolesOrderBy.Name)} />
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Slug
<IconButton
variant="plain"
className={getClassName(RolesOrderBy.Slug)}
ariaLabel="sort"
onClick={() => handleSort(RolesOrderBy.Slug)}
>
<FontAwesomeIcon icon={getColSortIcon(RolesOrderBy.Slug)} />
</IconButton>
</div>
</Th>
<Th aria-label="actions" className="w-5" />
</Tr>
</THead>
<TBody>
{isRolesLoading && <TableSkeleton columns={4} innerKey="org-roles" />}
{roles?.map((role) => {
{isRolesLoading && <TableSkeleton columns={3} innerKey="project-roles" />}
{filteredRoles?.slice(offset, perPage * page).map((role) => {
const { id, name, slug } = role;
const isNonMutatable = Object.values(ProjectMembershipRole).includes(
slug as ProjectMembershipRole
@@ -109,88 +238,118 @@ export const ProjectRoleList = () => {
<Td>{name}</Td>
<Td>{slug}</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Role}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
navigate({
to: `/${currentWorkspace?.type}/$projectId/roles/$roleSlug` as const,
params: {
projectId: currentWorkspace.id,
roleSlug: slug
}
});
}}
disabled={!isAllowed}
>
{`${isNonMutatable ? "View" : "Edit"} Role`}
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Role}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("duplicateRole", role);
}}
disabled={!isAllowed}
>
Duplicate Role
</DropdownMenuItem>
)}
</ProjectPermissionCan>
{!isNonMutatable && (
<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 className="min-w-[12rem]" sideOffset={2} align="end">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Role}
>
{(isAllowed) => (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={isNonMutatable ? faEye : faEdit} />}
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteRole", role);
navigate({
to: `/${currentWorkspace?.type}/$projectId/roles/$roleSlug` as const,
params: {
projectId: currentWorkspace.id,
roleSlug: slug
}
});
}}
disabled={!isAllowed}
>
Delete Role
{`${isNonMutatable ? "View" : "Edit"} Role`}
</DropdownMenuItem>
)}
</ProjectPermissionCan>
)}
</DropdownMenuContent>
</DropdownMenu>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Role}
>
{(isAllowed) => (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faCopy} />}
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("duplicateRole", role);
}}
disabled={!isAllowed}
>
Duplicate Role
</DropdownMenuItem>
)}
</ProjectPermissionCan>
{!isNonMutatable && (
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Role}
>
{(isAllowed) => (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faTrash} />}
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50",
"transition-colors duration-100"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteRole", role);
}}
disabled={!isAllowed}
>
Delete Role
</DropdownMenuItem>
)}
</ProjectPermissionCan>
)}
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{Boolean(filteredRoles?.length) && (
<Pagination
count={filteredRoles!.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!filteredRoles?.length && !isRolesLoading && (
<EmptyState
title={
roles?.length
? "No project roles match search..."
: "This project does not have any roles"
}
icon={roles?.length ? faSearch : undefined}
/>
)}
</TableContainer>
<RoleModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
@@ -67,13 +67,12 @@ type Props = {
handlePopUpToggle: (popUpName: keyof UsePopUpState<["createAPIToken"]>, state?: boolean) => void;
};
export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => {
const ServiceTokenForm = () => {
const { t } = useTranslation();
const { currentWorkspace } = useWorkspace();
const {
control,
reset,
handleSubmit,
formState: { isSubmitting }
} = useForm<FormData>({
@@ -152,13 +151,197 @@ export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => {
}
};
return !hasServiceToken ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="name"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label={t("section.token.add-dialog.name")}
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Type your token name" />
</FormControl>
)}
/>
{tokenScopes.map(({ id }, index) => (
<div className="mb-3 flex items-end space-x-2" key={id}>
<Controller
control={control}
name={`scopes.${index}.environment`}
defaultValue={currentWorkspace?.environments?.[0]?.slug}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
className="mb-0"
label={index === 0 ? "Environment" : undefined}
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{currentWorkspace?.environments.map(({ name, slug }) => (
<SelectItem value={slug} key={slug}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name={`scopes.${index}.secretPath`}
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Secrets Path" : undefined}
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="can be /, /nested/**, /**/deep" />
</FormControl>
)}
/>
<IconButton
className="p-3"
ariaLabel="remove"
colorSchema="danger"
onClick={() => remove(index)}
>
<FontAwesomeIcon icon={faTrashCan} size="sm" />
</IconButton>
</div>
))}
<div className="my-4 ml-1">
<Button
variant="outline_bg"
onClick={() =>
append({
environment: currentWorkspace?.environments?.[0]?.slug || "",
secretPath: ""
})
}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
Add Scope
</Button>
</div>
<Controller
control={control}
name="expiresIn"
defaultValue={String(apiTokenExpiry?.[0]?.value)}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Expiration" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{apiTokenExpiry.map(({ label, value }) => (
<SelectItem value={String(value)} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="permissions"
defaultValue={{
read: true,
readValue: false,
write: false
}}
render={({ field: { onChange, value }, fieldState: { error } }) => {
const options = [
{
label: "Read (default)",
value: "read"
},
{
label: "Write (optional)",
value: "write"
}
] as const;
return (
<FormControl label="Permissions" errorText={error?.message} isError={Boolean(error)}>
<>
{options.map(({ label, value: optionValue }) => {
return (
<Checkbox
id={String(value[optionValue])}
key={optionValue}
className="data-[state=checked]:bg-primary"
isChecked={value[optionValue]}
isDisabled={optionValue === "read"}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
})}
</>
</FormControl>
);
}}
/>
<div className="mt-8 flex items-center">
<Button className="mr-4" type="submit" isDisabled={isSubmitting} isLoading={isSubmitting}>
Create
</Button>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</ModalClose>
</div>
</form>
) : (
<div className="mb-3 mr-2 mt-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{newToken}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={copyTokenToClipboard}
>
<FontAwesomeIcon icon={isTokenCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{t("common.click-to-copy")}
</span>
</IconButton>
</div>
);
};
export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => {
const { t } = useTranslation();
const { currentWorkspace } = useWorkspace();
return (
<Modal
isOpen={popUp?.createAPIToken?.isOpen}
onOpenChange={(open) => {
handlePopUpToggle("createAPIToken", open);
reset();
setToken("");
}}
>
<ModalContent
@@ -169,194 +352,7 @@ export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => {
}
subTitle={t("section.token.add-dialog.description") as string}
>
{!hasServiceToken ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="name"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label={t("section.token.add-dialog.name")}
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Type your token name" />
</FormControl>
)}
/>
{tokenScopes.map(({ id }, index) => (
<div className="mb-3 flex items-end space-x-2" key={id}>
<Controller
control={control}
name={`scopes.${index}.environment`}
defaultValue={currentWorkspace?.environments?.[0]?.slug}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
className="mb-0"
label={index === 0 ? "Environment" : undefined}
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{currentWorkspace?.environments.map(({ name, slug }) => (
<SelectItem value={slug} key={slug}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name={`scopes.${index}.secretPath`}
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Secrets Path" : undefined}
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="can be /, /nested/**, /**/deep" />
</FormControl>
)}
/>
<IconButton
className="p-3"
ariaLabel="remove"
colorSchema="danger"
onClick={() => remove(index)}
>
<FontAwesomeIcon icon={faTrashCan} size="sm" />
</IconButton>
</div>
))}
<div className="my-4 ml-1">
<Button
variant="outline_bg"
onClick={() =>
append({
environment: currentWorkspace?.environments?.[0]?.slug || "",
secretPath: ""
})
}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
Add Scope
</Button>
</div>
<Controller
control={control}
name="expiresIn"
defaultValue={String(apiTokenExpiry?.[0]?.value)}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Expiration" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{apiTokenExpiry.map(({ label, value }) => (
<SelectItem value={String(value)} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="permissions"
defaultValue={{
read: true,
readValue: false,
write: false
}}
render={({ field: { onChange, value }, fieldState: { error } }) => {
const options = [
{
label: "Read (default)",
value: "read"
},
{
label: "Write (optional)",
value: "write"
}
] as const;
return (
<FormControl
label="Permissions"
errorText={error?.message}
isError={Boolean(error)}
>
<>
{options.map(({ label, value: optionValue }) => {
return (
<Checkbox
id={String(value[optionValue])}
key={optionValue}
className="data-[state=checked]:bg-primary"
isChecked={value[optionValue]}
isDisabled={optionValue === "read"}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
})}
</>
</FormControl>
);
}}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
type="submit"
isDisabled={isSubmitting}
isLoading={isSubmitting}
>
Create
</Button>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</ModalClose>
</div>
</form>
) : (
<div className="mb-3 mr-2 mt-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{newToken}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={copyTokenToClipboard}
>
<FontAwesomeIcon icon={isTokenCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{t("common.click-to-copy")}
</span>
</IconButton>
</div>
)}
<ServiceTokenForm />
</ModalContent>
</Modal>
);
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
@@ -48,8 +48,29 @@ export const ServiceTokenSection = withProjectPermission(
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-2 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Service Tokens</p>
<div className="mb-2 flex items-center justify-between">
<div>
<div className="flex items-center gap-1">
<p className="text-xl font-semibold text-mineshaft-100">Service Tokens</p>
<a
href="https://infisical.com/docs/documentation/platform/token"
target="_blank"
rel="noopener noreferrer"
>
<div className="ml-1 mt-[0.16rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1.5 text-[10px]"
/>
</div>
</a>
</div>
<p className="text-sm text-bunker-300">
{t("section.token.service-tokens-description")}
</p>
</div>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.ServiceTokens}
@@ -63,12 +84,11 @@ export const ServiceTokenSection = withProjectPermission(
}}
isDisabled={!isAllowed}
>
Create token
Create Token
</Button>
)}
</ProjectPermissionCan>
</div>
<p className="mb-8 text-gray-400">{t("section.token.service-tokens-description")}</p>
<ServiceTokenTable handlePopUpOpen={handlePopUpOpen} />
<AddServiceTokenModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
@@ -1,10 +1,28 @@
import { faFolder, faKey, faTrashCan } from "@fortawesome/free-solid-svg-icons";
import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faEllipsisV,
faFolder,
faKey,
faMagnifyingGlass,
faSearch,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
@@ -12,10 +30,18 @@ import {
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetUserWsServiceTokens } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
@@ -31,78 +57,230 @@ type Props = {
) => void;
};
enum TokensOrderBy {
Name = "name",
Expiration = "expiration"
}
export const ServiceTokenTable = ({ handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace();
const { data, isPending } = useGetUserWsServiceTokens({
workspaceID: currentWorkspace?.id || ""
});
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
toggleOrderDirection,
orderBy,
setOrderDirection,
setOrderBy
} = usePagination<TokensOrderBy>(TokensOrderBy.Name, {
initPerPage: getUserTablePreference("projectServiceTokens", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("projectServiceTokens", PreferenceKey.PerPage, newPerPage);
};
const filteredTokens = useMemo(
() =>
data
?.filter((token) => {
const { name, scopes } = token;
const searchValue = search.trim().toLowerCase();
if (name.toLowerCase().includes(searchValue)) {
return true;
}
return scopes.some(
({ environment, secretPath }) =>
environment.toLowerCase().includes(searchValue) ||
secretPath.toLowerCase().includes(searchValue)
);
})
.sort((a, b) => {
const [tokenOne, tokenTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case TokensOrderBy.Expiration:
if (!tokenOne.expiresAt && !tokenTwo.expiresAt) return 0;
if (!tokenOne.expiresAt) return 1;
if (!tokenTwo.expiresAt) return -1;
return (
new Date(tokenOne.expiresAt).getTime() - new Date(tokenTwo.expiresAt).getTime()
);
case TokensOrderBy.Name:
default:
return tokenOne.name.toLowerCase().localeCompare(tokenTwo.name.toLowerCase());
}
}) ?? [],
[data, orderDirection, search, orderBy]
);
useResetPageHelper({
totalCount: filteredTokens.length,
offset,
setPage
});
const handleSort = (column: TokensOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
const getClassName = (col: TokensOrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: TokensOrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Token Name</Th>
<Th>Environment - Secret Path</Th>
<Th>Valid Until</Th>
<Th aria-label="button" />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="project-service-tokens" />}
{!isPending &&
data &&
data.map((row) => (
<Tr key={row.id}>
<Td>{row.name}</Td>
<Td>
<div className="mb-2 flex flex-col flex-wrap space-y-1">
{row?.scopes.map(({ secretPath, environment }) => (
<div
key={`${row.id}-${environment}-${secretPath}`}
className="inline-flex items-center space-x-1 rounded-md border border-mineshaft-600 p-1 px-2"
>
<div className="mr-2 border-r border-mineshaft-600 pr-2">{environment}</div>
<FontAwesomeIcon icon={faFolder} size="sm" />
<span className="pl-2">{secretPath}</span>
</div>
))}
</div>
</Td>
<Td>{row.expiresAt && new Date(row.expiresAt).toUTCString()}</Td>
<Td>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.ServiceTokens}
>
{(isAllowed) => (
<IconButton
onClick={() =>
handlePopUpOpen("deleteAPITokenConfirmation", {
name: row.name,
id: row.id
})
}
colorSchema="danger"
ariaLabel="delete"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faTrashCan} />
</IconButton>
)}
</ProjectPermissionCan>
</Td>
</Tr>
))}
{!isPending && data && data?.length === 0 && (
<div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search service tokens by name, environment or secret path..."
className="flex-1"
containerClassName="mb-4 mt-2"
/>
<TableContainer>
<Table>
<THead>
<Tr>
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
<EmptyState title="No service tokens found" icon={faKey} />
</Td>
<Th>
<div className="flex items-center">
Name
<IconButton
variant="plain"
className={getClassName(TokensOrderBy.Name)}
ariaLabel="sort"
onClick={() => handleSort(TokensOrderBy.Name)}
>
<FontAwesomeIcon icon={getColSortIcon(TokensOrderBy.Name)} />
</IconButton>
</div>
</Th>
<Th>Environment / Secret Path</Th>
<Th>
<div className="flex items-center">
Valid Until
<IconButton
variant="plain"
className={getClassName(TokensOrderBy.Expiration)}
ariaLabel="sort"
onClick={() => handleSort(TokensOrderBy.Expiration)}
>
<FontAwesomeIcon icon={getColSortIcon(TokensOrderBy.Expiration)} />
</IconButton>
</div>
</Th>
<Th className="w-5" aria-label="button" />
</Tr>
)}
</TBody>
</Table>
</TableContainer>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="project-service-tokens" />}
{!isPending &&
filteredTokens.slice(offset, perPage * page).map((row) => (
<Tr key={row.id}>
<Td>{row.name}</Td>
<Td>
<div className="flex flex-row flex-wrap gap-1">
{row?.scopes.map(({ secretPath, environment }) => (
<div
key={`${row.id}-${environment}-${secretPath}`}
className="inline-flex items-center space-x-1 rounded-md border border-mineshaft-500 p-1 px-2"
>
<div className="mr-1 border-r border-mineshaft-500 pr-2">
{environment}
</div>
<FontAwesomeIcon icon={faFolder} size="sm" className="text-yellow" />
<span className="pl-1">{secretPath}</span>
</div>
))}
</div>
</Td>
<Td>
{row.expiresAt ? (
format(row.expiresAt, "MM/dd/yyyy h:mm:ss aa")
) : (
<span className="text-mineshaft-400">N/A</span>
)}
</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 className="min-w-[12rem]" sideOffset={2} align="end">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.ServiceTokens}
>
{(isAllowed) => (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faTrash} />}
isDisabled={!isAllowed}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteAPITokenConfirmation", {
name: row.name,
id: row.id
});
}}
>
Delete Token
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</Td>
</Tr>
))}
</TBody>
</Table>
{Boolean(filteredTokens.length) && (
<Pagination
count={filteredTokens.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && !filteredTokens?.length && (
<EmptyState
title={data?.length ? "No service tokens match search..." : "No service tokens found"}
icon={data?.length ? faSearch : faKey}
/>
)}
</TableContainer>
</div>
);
};
@@ -291,6 +291,13 @@ export const projectRoleFormSchema = z.object({
})
.array()
.default([]),
[ProjectPermissionSub.SecretSyncs]: SecretSyncPolicyActionSchema.extend({
inverted: z.boolean().optional(),
conditions: ConditionSchema
})
.array()
.default([]),
[ProjectPermissionSub.Commits]: CommitPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Member]: MemberPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Groups]: GroupPolicyActionSchema.array().default([]),
@@ -342,7 +349,6 @@ export const projectRoleFormSchema = z.object({
.default([]),
[ProjectPermissionSub.Kms]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Cmek]: CmekPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretSyncs]: SecretSyncPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Kmip]: KmipPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretScanningDataSources]:
SecretScanningDataSourcePolicyActionSchema.array().default([]),
@@ -366,7 +372,8 @@ type TConditionalFields =
| ProjectPermissionSub.CertificateTemplates
| ProjectPermissionSub.SshHosts
| ProjectPermissionSub.SecretRotation
| ProjectPermissionSub.Identity;
| ProjectPermissionSub.Identity
| ProjectPermissionSub.SecretSyncs;
export const isConditionalSubjects = (
subject: ProjectPermissionSub
@@ -379,7 +386,8 @@ export const isConditionalSubjects = (
subject === ProjectPermissionSub.SshHosts ||
subject === ProjectPermissionSub.SecretRotation ||
subject === ProjectPermissionSub.PkiSubscribers ||
subject === ProjectPermissionSub.CertificateTemplates;
subject === ProjectPermissionSub.CertificateTemplates ||
subject === ProjectPermissionSub.SecretSyncs;
const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition) => {
const formConditions: z.infer<typeof ConditionSchema> = [];
@@ -484,7 +492,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
ProjectPermissionSub.SshCertificateTemplates,
ProjectPermissionSub.SshCertificateAuthorities,
ProjectPermissionSub.SshCertificates,
ProjectPermissionSub.SshHostGroups
ProjectPermissionSub.SshHostGroups,
ProjectPermissionSub.SecretSyncs
].includes(subject)
) {
// from above statement we are sure it won't be undefined
@@ -515,6 +524,36 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
return;
}
if (subject === ProjectPermissionSub.SecretSyncs) {
const canRead = action.includes(ProjectPermissionSecretSyncActions.Read);
const canEdit = action.includes(ProjectPermissionSecretSyncActions.Edit);
const canDelete = action.includes(ProjectPermissionSecretSyncActions.Delete);
const canCreate = action.includes(ProjectPermissionSecretSyncActions.Create);
const canSyncSecrets = action.includes(ProjectPermissionSecretSyncActions.SyncSecrets);
const canImportSecrets = action.includes(
ProjectPermissionSecretSyncActions.ImportSecrets
);
const canRemoveSecrets = action.includes(
ProjectPermissionSecretSyncActions.RemoveSecrets
);
if (!formVal[subject]) formVal[subject] = [{ conditions: [], inverted: false }];
// from above statement we are sure it won't be undefined
formVal[subject]!.push({
[ProjectPermissionSecretSyncActions.Read]: canRead,
[ProjectPermissionSecretSyncActions.Create]: canCreate,
[ProjectPermissionSecretSyncActions.Edit]: canEdit,
[ProjectPermissionSecretSyncActions.Delete]: canDelete,
[ProjectPermissionSecretSyncActions.SyncSecrets]: canSyncSecrets,
[ProjectPermissionSecretSyncActions.ImportSecrets]: canImportSecrets,
[ProjectPermissionSecretSyncActions.RemoveSecrets]: canRemoveSecrets,
conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [],
inverted
});
return;
}
if (subject === ProjectPermissionSub.DynamicSecrets) {
const canRead = action.includes(ProjectPermissionDynamicSecretActions.ReadRootCredential);
const canEdit = action.includes(ProjectPermissionDynamicSecretActions.EditRootCredential);
@@ -777,31 +816,6 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
return;
}
if (subject === ProjectPermissionSub.SecretSyncs) {
const canRead = action.includes(ProjectPermissionSecretSyncActions.Read);
const canEdit = action.includes(ProjectPermissionSecretSyncActions.Edit);
const canDelete = action.includes(ProjectPermissionSecretSyncActions.Delete);
const canCreate = action.includes(ProjectPermissionSecretSyncActions.Create);
const canSyncSecrets = action.includes(ProjectPermissionSecretSyncActions.SyncSecrets);
const canImportSecrets = action.includes(ProjectPermissionSecretSyncActions.ImportSecrets);
const canRemoveSecrets = action.includes(ProjectPermissionSecretSyncActions.RemoveSecrets);
if (!formVal[subject]) formVal[subject] = [{}];
// from above statement we are sure it won't be undefined
if (canRead) formVal[subject]![0][ProjectPermissionSecretSyncActions.Read] = true;
if (canEdit) formVal[subject]![0][ProjectPermissionSecretSyncActions.Edit] = true;
if (canCreate) formVal[subject]![0][ProjectPermissionSecretSyncActions.Create] = true;
if (canDelete) formVal[subject]![0][ProjectPermissionSecretSyncActions.Delete] = true;
if (canSyncSecrets)
formVal[subject]![0][ProjectPermissionSecretSyncActions.SyncSecrets] = true;
if (canImportSecrets)
formVal[subject]![0][ProjectPermissionSecretSyncActions.ImportSecrets] = true;
if (canRemoveSecrets)
formVal[subject]![0][ProjectPermissionSecretSyncActions.RemoveSecrets] = true;
return;
}
if (subject === ProjectPermissionSub.SecretScanningDataSources) {
const canRead = action.includes(ProjectPermissionSecretScanningDataSourceActions.Read);
const canEdit = action.includes(ProjectPermissionSecretScanningDataSourceActions.Edit);
@@ -35,6 +35,7 @@ import {
TFormSchema
} from "./ProjectRoleModifySection.utils";
import { SecretPermissionConditions } from "./SecretPermissionConditions";
import { SecretSyncPermissionConditions } from "./SecretSyncPermissionConditions";
import { SshHostPermissionConditions } from "./SshHostPermissionConditions";
type Props = {
@@ -69,6 +70,10 @@ export const renderConditionalComponents = (
return <PkiTemplatePermissionConditions isDisabled={isDisabled} />;
}
if (subject === ProjectPermissionSub.SecretSyncs) {
return <SecretSyncPermissionConditions isDisabled={isDisabled} />;
}
return <GeneralPermissionConditions isDisabled={isDisabled} type={subject} />;
}
@@ -0,0 +1,186 @@
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
import { faInfoCircle, faPlus, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button,
FormControl,
IconButton,
Input,
Select,
SelectItem,
Tooltip
} from "@app/components/v2";
import {
PermissionConditionOperators,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import {
getConditionOperatorHelperInfo,
renderOperatorSelectItems
} from "./PermissionConditionHelpers";
import { TFormSchema } from "./ProjectRoleModifySection.utils";
type Props = {
position?: number;
isDisabled?: boolean;
};
export const SecretSyncPermissionConditions = ({ position = 0, isDisabled }: Props) => {
const {
control,
watch,
setValue,
formState: { errors }
} = useFormContext<TFormSchema>();
const items = useFieldArray({
control,
name: `permissions.${ProjectPermissionSub.SecretSyncs}.${position}.conditions`
});
const conditionErrorMessage =
errors?.permissions?.[ProjectPermissionSub.SecretSyncs]?.[position]?.conditions?.message ||
errors?.permissions?.[ProjectPermissionSub.SecretSyncs]?.[position]?.conditions?.root?.message;
return (
<div className="mt-6 border-t border-t-mineshaft-600 bg-mineshaft-800 pt-2">
<p className="mt-2 text-gray-300">Conditions</p>
<p className="text-sm text-mineshaft-400">
Conditions determine when a policy will be applied (always if no conditions are present).
</p>
<p className="mb-3 text-sm leading-4 text-mineshaft-400">
All conditions must evaluate to true for the policy to take effect.
</p>
<div className="mt-2 flex flex-col space-y-2">
{items.fields.map((el, index) => {
const condition = watch(
`permissions.${ProjectPermissionSub.SecretSyncs}.${position}.conditions.${index}`
) as {
lhs: string;
rhs: string;
operator: string;
};
return (
<div
key={el.id}
className="flex gap-2 bg-mineshaft-800 first:rounded-t-md last:rounded-b-md"
>
<div className="w-1/4">
<Controller
control={control}
name={`permissions.${ProjectPermissionSub.SecretSyncs}.${position}.conditions.${index}.lhs`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => {
setValue(
`permissions.${ProjectPermissionSub.SecretSyncs}.${position}.conditions.${index}.operator`,
PermissionConditionOperators.$IN as never
);
field.onChange(e);
}}
className="w-full"
>
<SelectItem value="environment">Environment Slug</SelectItem>
<SelectItem value="secretPath">Secret Path</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
<div className="flex w-36 items-center space-x-2">
<Controller
control={control}
name={`permissions.${ProjectPermissionSub.SecretSyncs}.${position}.conditions.${index}.operator`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => field.onChange(e)}
className="w-full"
>
{renderOperatorSelectItems(condition.lhs)}
</Select>
</FormControl>
)}
/>
<div>
<Tooltip
asChild
content={getConditionOperatorHelperInfo(
condition?.operator as PermissionConditionOperators
)}
className="max-w-xs"
>
<FontAwesomeIcon icon={faInfoCircle} size="xs" className="text-gray-400" />
</Tooltip>
</div>
</div>
<div className="flex-grow">
<Controller
control={control}
name={`permissions.${ProjectPermissionSub.SecretSyncs}.${position}.conditions.${index}.rhs`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div>
<IconButton
ariaLabel="remove"
variant="outline_bg"
className="p-2.5"
onClick={() => items.remove(index)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
</div>
);
})}
</div>
{conditionErrorMessage && (
<div className="flex items-center space-x-2 py-2 text-sm text-gray-400">
<FontAwesomeIcon icon={faWarning} className="text-red" />
<span>{conditionErrorMessage}</span>
</div>
)}
<div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
variant="star"
size="xs"
className="mt-3"
isDisabled={isDisabled}
onClick={() =>
items.append({
lhs: "environment",
operator: PermissionConditionOperators.$EQ,
rhs: ""
})
}
>
Add Condition
</Button>
</div>
</div>
);
};
@@ -0,0 +1,14 @@
import { TFlyioSync } from "@app/hooks/api/secretSyncs/types/flyio-sync";
import { getSecretSyncDestinationColValues } from "../helpers";
import { SecretSyncTableCell } from "../SecretSyncTableCell";
type Props = {
secretSync: TFlyioSync;
};
export const FlyioSyncDestinationCol = ({ secretSync }: Props) => {
const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync);
return <SecretSyncTableCell primaryText={primaryText} secondaryText={secondaryText} />;
};
@@ -8,6 +8,7 @@ import { AzureDevOpsSyncDestinationCol } from "./AzureDevOpsSyncDestinationCol";
import { AzureKeyVaultDestinationSyncCol } from "./AzureKeyVaultDestinationSyncCol";
import { CamundaSyncDestinationCol } from "./CamundaSyncDestinationCol";
import { DatabricksSyncDestinationCol } from "./DatabricksSyncDestinationCol";
import { FlyioSyncDestinationCol } from "./FlyioSyncDestinationCol";
import { GcpSyncDestinationCol } from "./GcpSyncDestinationCol";
import { GitHubSyncDestinationCol } from "./GitHubSyncDestinationCol";
import { HCVaultSyncDestinationCol } from "./HCVaultSyncDestinationCol";
@@ -61,6 +62,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => {
return <AzureDevOpsSyncDestinationCol secretSync={secretSync} />;
case SecretSync.Render:
return <RenderSyncDestinationCol secretSync={secretSync} />;
case SecretSync.Flyio:
return <FlyioSyncDestinationCol secretSync={secretSync} />;
default:
throw new Error(
`Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}`
@@ -1,4 +1,5 @@
import { useCallback, useMemo } from "react";
import { subject } from "@casl/ability";
import {
faBan,
faCalendarCheck,
@@ -117,6 +118,14 @@ export const SecretSyncRow = ({
const destinationDetails = SECRET_SYNC_MAP[destination];
const permissionSubject =
environment && folder
? subject(ProjectPermissionSub.SecretSyncs, {
environment: environment.slug,
secretPath: folder.path
})
: ProjectPermissionSub.SecretSyncs;
return (
<Tr
onClick={() =>
@@ -264,7 +273,7 @@ export const SecretSyncRow = ({
</DropdownMenuItem>
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.SyncSecrets}
a={ProjectPermissionSub.SecretSyncs}
a={permissionSubject}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
@@ -295,7 +304,7 @@ export const SecretSyncRow = ({
{syncOption?.canImportSecrets && (
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.ImportSecrets}
a={ProjectPermissionSub.SecretSyncs}
a={permissionSubject}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
@@ -326,7 +335,7 @@ export const SecretSyncRow = ({
)}
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.RemoveSecrets}
a={ProjectPermissionSub.SecretSyncs}
a={permissionSubject}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
@@ -356,7 +365,7 @@ export const SecretSyncRow = ({
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.Edit}
a={ProjectPermissionSub.SecretSyncs}
a={permissionSubject}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
@@ -373,7 +382,7 @@ export const SecretSyncRow = ({
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.Delete}
a={ProjectPermissionSub.SecretSyncs}
a={permissionSubject}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
@@ -120,6 +120,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => {
primaryText = destinationConfig.serviceName ?? destinationConfig.serviceId;
secondaryText = "Service";
break;
case SecretSync.Flyio:
primaryText = destinationConfig.appId;
secondaryText = "App ID";
break;
default:
throw new Error(`Unhandled Destination Col Values ${destination}`);
}
@@ -96,6 +96,10 @@ import { SecretOverviewSecretRotationRow } from "@app/pages/secret-manager/Overv
import { CreateDynamicSecretForm } from "../SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm";
import { FolderForm } from "../SecretDashboardPage/components/ActionBar/FolderForm";
import {
HIDDEN_SECRET_VALUE,
HIDDEN_SECRET_VALUE_API_MASK
} from "../SecretDashboardPage/components/SecretListView/SecretItem";
import { CreateSecretForm } from "./components/CreateSecretForm";
import { FolderBreadCrumbs } from "./components/FolderBreadCrumbs";
import { SecretOverviewDynamicSecretRow } from "./components/SecretOverviewDynamicSecretRow";
@@ -509,15 +513,25 @@ export const OverviewPage = () => {
env: string,
key: string,
value: string,
secretValueHidden: boolean,
type = SecretType.Shared
) => {
let secretValue: string | undefined = value;
if (
secretValueHidden &&
(value === HIDDEN_SECRET_VALUE_API_MASK || value === HIDDEN_SECRET_VALUE)
) {
secretValue = undefined;
}
try {
const result = await updateSecretV3({
environment: env,
workspaceId,
secretPath,
secretKey: key,
secretValue: value,
secretValue,
type
});
@@ -50,6 +50,7 @@ type Props = {
env: string,
key: string,
value: string,
secretValueHidden: boolean,
type?: SecretType,
secretId?: string
) => Promise<void>;
@@ -147,6 +148,7 @@ export const SecretEditRow = ({
environment,
secretName,
value,
secretValueHidden,
isOverride ? SecretType.Personal : SecretType.Shared,
secretId
);
@@ -166,6 +168,7 @@ export const SecretEditRow = ({
environment,
secretName,
secretValue,
secretValueHidden,
isOverride ? SecretType.Personal : SecretType.Shared,
secretId
);
@@ -257,7 +260,7 @@ export const SecretEditRow = ({
>
{(isAllowed) => (
<div>
<Tooltip content="save">
<Tooltip content="Save">
<IconButton
variant="plain"
ariaLabel="submit-value"
@@ -24,6 +24,7 @@ import { useToggle } from "@app/hooks";
import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
import { WorkspaceEnv } from "@app/hooks/api/types";
import { getExpandedRowStyle } from "@app/pages/secret-manager/OverviewPage/components/utils";
import { HIDDEN_SECRET_VALUE } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem";
import { SecretEditRow } from "./SecretEditRow";
import SecretRenameRow from "./SecretRenameRow";
@@ -40,6 +41,7 @@ type Props = {
env: string,
key: string,
value: string,
secretValueHidden: boolean,
type?: SecretType,
secretId?: string
) => Promise<void>;
@@ -96,7 +98,7 @@ export const SecretOverviewTableRow = ({
);
if (secret?.secretValueHidden && !secret?.valueOverride) {
return canEditSecretValue ? "******" : "";
return canEditSecretValue ? HIDDEN_SECRET_VALUE : "";
}
return secret?.valueOverride || secret?.value || importedSecret?.secret?.value || "";
};
@@ -11,6 +11,7 @@ import {
faKey
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { SecretInput, Tag, Tooltip } from "@app/components/v2";
import { CommitType, SecretV3Raw, TSecretApprovalSecChange, WsTag } from "@app/hooks/api/types";
@@ -105,21 +106,41 @@ export const SecretApprovalRequestChangeItem = ({
</span>
) : (
<div className="relative">
{secretVersion?.secretValueHidden && (
<div className="absolute left-1 top-1/2 z-50 -translate-y-1/2">
<Tooltip
position="right"
content="You do not have access to view the old secret value."
>
<FontAwesomeIcon
className="pl-2 text-mineshaft-300"
size="sm"
icon={faEyeSlash}
/>
</Tooltip>
</div>
)}
<SecretInput
isReadOnly
isVisible={isOldSecretValueVisible}
valueAlwaysHidden={secretVersion?.secretValueHidden}
value={secretVersion?.secretValue}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-bunker-700 px-2 py-1.5"
containerClassName={twMerge(
"border border-mineshaft-600 bg-bunker-700 py-1.5 text-bunker-300 hover:border-primary-400/50",
secretVersion?.secretValueHidden ? "pl-8 pr-2" : "px-2"
)}
/>
<div
className="absolute right-1 top-1"
onClick={() => setIsOldSecretValueVisible(!isOldSecretValueVisible)}
>
<FontAwesomeIcon
icon={isOldSecretValueVisible ? faEyeSlash : faEye}
className="cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-800 p-1.5 text-mineshaft-300 hover:bg-mineshaft-700"
/>
</div>
{!secretVersion?.secretValueHidden && (
<div
className="absolute right-1 top-1"
onClick={() => setIsOldSecretValueVisible(!isOldSecretValueVisible)}
>
<FontAwesomeIcon
icon={isOldSecretValueVisible ? faEyeSlash : faEye}
className="cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-800 p-1.5 text-mineshaft-300 hover:bg-mineshaft-700"
/>
</div>
)}
</div>
)}
</div>
@@ -211,21 +232,41 @@ export const SecretApprovalRequestChangeItem = ({
</span>
) : (
<div className="relative">
{newVersion?.secretValueHidden && (
<div className="absolute left-1 top-1/2 z-50 -translate-y-1/2">
<Tooltip
position="right"
content="You do not have access to view the new secret value."
>
<FontAwesomeIcon
className="pl-2 text-mineshaft-300"
size="sm"
icon={faEyeSlash}
/>
</Tooltip>
</div>
)}
<SecretInput
isReadOnly
valueAlwaysHidden={newVersion?.secretValueHidden}
isVisible={isNewSecretValueVisible}
value={newVersion?.secretValue}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-bunker-700 px-2 py-1.5"
containerClassName={twMerge(
"border border-mineshaft-600 bg-bunker-700 py-1.5 text-bunker-300 hover:border-primary-400/50",
newVersion?.secretValueHidden ? "pl-8 pr-2" : "px-2"
)}
/>
<div
className="absolute right-1 top-1"
onClick={() => setIsNewSecretValueVisible(!isNewSecretValueVisible)}
>
<FontAwesomeIcon
icon={isNewSecretValueVisible ? faEyeSlash : faEye}
className="cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-800 p-1.5 text-mineshaft-300 hover:bg-mineshaft-700"
/>
</div>
{!newVersion?.secretValueHidden && (
<div
className="absolute right-1 top-1"
onClick={() => setIsNewSecretValueVisible(!isNewSecretValueVisible)}
>
<FontAwesomeIcon
icon={isNewSecretValueVisible ? faEyeSlash : faEye}
className="cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-800 p-1.5 text-mineshaft-300 hover:bg-mineshaft-700"
/>
</div>
)}
</div>
)}
</div>
@@ -62,6 +62,7 @@ import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
import { camelCaseToSpaces } from "@app/lib/fn/string";
import { CreateReminderForm } from "./CreateReminderForm";
import { HIDDEN_SECRET_VALUE } from "./SecretItem";
import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils";
type Props = {
@@ -897,7 +898,9 @@ export const SecretDetailSidebar = ({
</button>
</div>
<span className="group-[.show-value]:hidden">
{secretValueHidden ? "******" : secretValue?.replace(/./g, "*")}
{secretValueHidden
? HIDDEN_SECRET_VALUE
: secretValue?.replace(/./g, "*")}
<button
type="button"
className="ml-1 cursor-pointer"
@@ -59,6 +59,7 @@ import {
import { CollapsibleSecretImports } from "./CollapsibleSecretImports";
export const HIDDEN_SECRET_VALUE = "******";
export const HIDDEN_SECRET_VALUE_API_MASK = "<hidden-by-infisical>";
type Props = {
secret: SecretV3RawSanitized;
@@ -20,7 +20,7 @@ import { AddShareSecretModal } from "@app/pages/organization/SecretSharingPage/c
import { useSelectedSecretActions, useSelectedSecrets } from "../../SecretMainPage.store";
import { CollapsibleSecretImports } from "./CollapsibleSecretImports";
import { SecretDetailSidebar } from "./SecretDetailSidebar";
import { SecretItem } from "./SecretItem";
import { HIDDEN_SECRET_VALUE, HIDDEN_SECRET_VALUE_API_MASK, SecretItem } from "./SecretItem";
import { FontAwesomeSpriteSymbols } from "./SecretListView.utils";
type Props = {
@@ -85,6 +85,7 @@ export const SecretListView = ({
type: SecretType,
key: string,
{
secretValueHidden,
value,
comment,
reminderRepeatDays,
@@ -97,6 +98,7 @@ export const SecretListView = ({
secretMetadata,
isRotatedSecret
}: Partial<{
secretValueHidden: boolean;
value: string;
comment: string;
reminderRepeatDays: number | null;
@@ -123,6 +125,15 @@ export const SecretListView = ({
}
if (operation === "update") {
let secretValue = value;
if (
secretValueHidden &&
(value === HIDDEN_SECRET_VALUE_API_MASK || value === HIDDEN_SECRET_VALUE)
) {
secretValue = undefined;
}
await updateSecretV3({
environment,
workspaceId,
@@ -130,7 +141,7 @@ export const SecretListView = ({
secretKey: key,
...(!isRotatedSecret && {
newSecretName: newKey,
secretValue: value || ""
secretValue: secretValueHidden ? secretValue : secretValue || ""
}),
type,
tagIds: tags,
@@ -168,7 +179,7 @@ export const SecretListView = ({
},
cb?: () => void
) => {
const { key: oldKey } = orgSecret;
const { key: oldKey, secretValueHidden } = orgSecret;
const {
key,
value,
@@ -246,7 +257,8 @@ export const SecretListView = ({
newKey: hasKeyChanged ? key : undefined,
skipMultilineEncoding: modSecret.skipMultilineEncoding,
secretMetadata,
isRotatedSecret: orgSecret.isRotatedSecret
isRotatedSecret: orgSecret.isRotatedSecret,
secretValueHidden
});
if (cb) cb();
}
@@ -1,4 +1,5 @@
import { useCallback } from "react";
import { subject } from "@casl/ability";
import {
faBan,
faCheck,
@@ -63,7 +64,7 @@ export const SecretSyncActionTriggers = ({ secretSync }: Props) => {
const triggerSyncSecrets = useTriggerSecretSyncSyncSecrets();
const updateSync = useUpdateSecretSync();
const { destination } = secretSync;
const { destination, environment, folder } = secretSync;
const destinationName = SECRET_SYNC_MAP[destination].name;
const { syncOption } = useSecretSyncOption(destination);
@@ -128,6 +129,14 @@ export const SecretSyncActionTriggers = ({ secretSync }: Props) => {
}
};
const permissionSubject =
environment && folder
? subject(ProjectPermissionSub.SecretSyncs, {
environment: environment.slug,
secretPath: folder.path
})
: ProjectPermissionSub.SecretSyncs;
return (
<>
<div className="ml-auto mt-4 flex flex-wrap items-center justify-end gap-2">
@@ -157,7 +166,7 @@ export const SecretSyncActionTriggers = ({ secretSync }: Props) => {
<div>
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.SyncSecrets}
a={ProjectPermissionSub.SecretSyncs}
a={permissionSubject}
>
{(isAllowed: boolean) => (
<Button
@@ -194,7 +203,7 @@ export const SecretSyncActionTriggers = ({ secretSync }: Props) => {
{syncOption?.canImportSecrets && (
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.ImportSecrets}
a={ProjectPermissionSub.SecretSyncs}
a={permissionSubject}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
@@ -222,7 +231,7 @@ export const SecretSyncActionTriggers = ({ secretSync }: Props) => {
)}
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.RemoveSecrets}
a={ProjectPermissionSub.SecretSyncs}
a={permissionSubject}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
@@ -249,7 +258,7 @@ export const SecretSyncActionTriggers = ({ secretSync }: Props) => {
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.Edit}
a={ProjectPermissionSub.SecretSyncs}
a={permissionSubject}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
@@ -267,7 +276,7 @@ export const SecretSyncActionTriggers = ({ secretSync }: Props) => {
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.Delete}
a={ProjectPermissionSub.SecretSyncs}
a={permissionSubject}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
@@ -0,0 +1,14 @@
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TFlyioSync } from "@app/hooks/api/secretSyncs/types/flyio-sync";
type Props = {
secretSync: TFlyioSync;
};
export const FlyioSyncDestinationSection = ({ secretSync }: Props) => {
const {
destinationConfig: { appId }
} = secretSync;
return <GenericFieldLabel label="App ID">{appId}</GenericFieldLabel>;
};
@@ -1,4 +1,5 @@
import { ReactNode } from "react";
import { subject } from "@casl/ability";
import { faEdit } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -18,6 +19,7 @@ import { AzureDevOpsSyncDestinationSection } from "./AzureDevOpsSyncDestinationS
import { AzureKeyVaultSyncDestinationSection } from "./AzureKeyVaultSyncDestinationSection";
import { CamundaSyncDestinationSection } from "./CamundaSyncDestinationSection";
import { DatabricksSyncDestinationSection } from "./DatabricksSyncDestinationSection";
import { FlyioSyncDestinationSection } from "./FlyioSyncDestinationSection";
import { GcpSyncDestinationSection } from "./GcpSyncDestinationSection";
import { GitHubSyncDestinationSection } from "./GitHubSyncDestinationSection";
import { HCVaultSyncDestinationSection } from "./HCVaultSyncDestinationSection";
@@ -35,7 +37,7 @@ type Props = {
};
export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: Props) => {
const { destination, connection } = secretSync;
const { destination, connection, folder, environment } = secretSync;
const app = APP_CONNECTION_MAP[connection.app].name;
@@ -97,18 +99,26 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }:
case SecretSync.Render:
DestinationComponents = <RenderSyncDestinationSection secretSync={secretSync} />;
break;
case SecretSync.Flyio:
DestinationComponents = <FlyioSyncDestinationSection secretSync={secretSync} />;
break;
default:
throw new Error(`Unhandled Destination Section components: ${destination}`);
}
const permissionSubject =
environment && folder
? subject(ProjectPermissionSub.SecretSyncs, {
environment: environment.slug,
secretPath: folder.path
})
: ProjectPermissionSub.SecretSyncs;
return (
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="font-semibold text-mineshaft-100">Destination Configuration</h3>
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.Edit}
a={ProjectPermissionSub.SecretSyncs}
>
<ProjectPermissionCan I={ProjectPermissionSecretSyncActions.Edit} a={permissionSubject}>
{(isAllowed) => (
<IconButton
variant="plain"
@@ -1,4 +1,5 @@
import { useMemo } from "react";
import { subject } from "@casl/ability";
import { faEdit } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
@@ -16,7 +17,8 @@ type Props = {
};
export const SecretSyncDetailsSection = ({ secretSync, onEditDetails }: Props) => {
const { syncStatus, lastSyncMessage, lastSyncedAt, name, description } = secretSync;
const { syncStatus, lastSyncMessage, lastSyncedAt, name, description, environment, folder } =
secretSync;
const failureMessage = useMemo(() => {
if (syncStatus === SecretSyncStatus.Failed) {
@@ -32,14 +34,19 @@ export const SecretSyncDetailsSection = ({ secretSync, onEditDetails }: Props) =
return null;
}, [syncStatus, lastSyncMessage]);
const permissionSubject =
environment && folder
? subject(ProjectPermissionSub.SecretSyncs, {
environment: environment.slug,
secretPath: folder.path
})
: ProjectPermissionSub.SecretSyncs;
return (
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="font-semibold text-mineshaft-100">Details</h3>
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.Edit}
a={ProjectPermissionSub.SecretSyncs}
>
<ProjectPermissionCan I={ProjectPermissionSecretSyncActions.Edit} a={permissionSubject}>
{(isAllowed) => (
<IconButton
variant="plain"
@@ -1,4 +1,5 @@
import { ReactNode } from "react";
import { subject } from "@casl/ability";
import { faEdit } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -21,7 +22,9 @@ type Props = {
export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) => {
const {
destination,
syncOptions: { initialSyncBehavior, disableSecretDeletion, keySchema }
syncOptions: { initialSyncBehavior, disableSecretDeletion, keySchema },
environment,
folder
} = secretSync;
let AdditionalSyncOptionsComponent: ReactNode;
@@ -53,21 +56,27 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) =
case SecretSync.OCIVault:
case SecretSync.OnePass:
case SecretSync.Render:
case SecretSync.Flyio:
AdditionalSyncOptionsComponent = null;
break;
default:
throw new Error(`Unhandled Destination Review Fields: ${destination}`);
}
const permissionSubject =
environment && folder
? subject(ProjectPermissionSub.SecretSyncs, {
environment: environment.slug,
secretPath: folder.path
})
: ProjectPermissionSub.SecretSyncs;
return (
<div>
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="font-semibold text-mineshaft-100">Sync Options</h3>
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.Edit}
a={ProjectPermissionSub.SecretSyncs}
>
<ProjectPermissionCan I={ProjectPermissionSecretSyncActions.Edit} a={permissionSubject}>
{(isAllowed) => (
<IconButton
variant="plain"
@@ -1,3 +1,4 @@
import { subject } from "@casl/ability";
import { faEdit, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -16,6 +17,14 @@ type Props = {
export const SecretSyncSourceSection = ({ secretSync, onEditSource }: Props) => {
const { folder, environment } = secretSync;
const permissionSubject =
environment && folder
? subject(ProjectPermissionSub.SecretSyncs, {
environment: environment.slug,
secretPath: folder.path
})
: ProjectPermissionSub.SecretSyncs;
return (
<div>
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
@@ -35,10 +44,7 @@ export const SecretSyncSourceSection = ({ secretSync, onEditSource }: Props) =>
</div>
</Tooltip>
)}
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.Edit}
a={ProjectPermissionSub.SecretSyncs}
>
<ProjectPermissionCan I={ProjectPermissionSecretSyncActions.Edit} a={permissionSubject}>
{(isAllowed) => (
<IconButton
variant="plain"