mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-13 22:29:33 +00:00
Fix merge conflicts
This commit is contained in:
@@ -56,18 +56,20 @@ export default function NavHeader({
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center pt-6">
|
||||
<div className="mr-2 flex h-5 w-5 items-center justify-center rounded-md bg-primary text-sm text-black">
|
||||
<div className="mr-2 flex h-5 w-5 items-center justify-center rounded-md bg-primary text-sm text-black min-w-[1.25rem]">
|
||||
{currentOrg?.name?.charAt(0)}
|
||||
</div>
|
||||
<Link passHref legacyBehavior href={`/org/${currentOrg?.id}/overview`}>
|
||||
<a className="pl-0.5 text-sm font-semibold text-primary/80 hover:text-primary">
|
||||
<a className="truncate pl-0.5 text-sm font-semibold text-primary/80 hover:text-primary">
|
||||
{currentOrg?.name}
|
||||
</a>
|
||||
</Link>
|
||||
{isProjectRelated && (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faAngleRight} className="ml-3 mr-3 text-xs text-gray-400" />
|
||||
<div className="text-sm font-semibold text-bunker-300">{currentWorkspace?.name}</div>
|
||||
<div className="truncate text-sm font-semibold text-bunker-300">
|
||||
{currentWorkspace?.name}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isOrganizationRelated && (
|
||||
|
||||
@@ -245,6 +245,7 @@ export default function UserInfoStep({
|
||||
placeholder="Infisical"
|
||||
onChange={(e) => setOrganizationName(e.target.value)}
|
||||
value={organizationName}
|
||||
maxLength={64}
|
||||
isRequired
|
||||
className="h-12"
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,7 @@ export const Popover = PopoverPrimitive.Root;
|
||||
|
||||
export type PopoverContentProps = {
|
||||
children?: ReactNode;
|
||||
arrowClassName?: string;
|
||||
hideCloseBtn?: boolean;
|
||||
} & PopoverPrimitive.PopoverContentProps;
|
||||
|
||||
@@ -19,6 +20,7 @@ export const PopoverContent = ({
|
||||
children,
|
||||
className,
|
||||
hideCloseBtn,
|
||||
arrowClassName,
|
||||
...props
|
||||
}: PopoverContentProps) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
@@ -48,7 +50,7 @@ export const PopoverContent = ({
|
||||
</IconButton>
|
||||
</PopoverPrimitive.Close>
|
||||
)}
|
||||
<PopoverPrimitive.Arrow className="fill-inherit" />
|
||||
<PopoverPrimitive.Arrow className={twMerge("fill-inherit", arrowClassName)} />
|
||||
</PopoverPrimitive.Content>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TOrgRole, TProjectRole } from "../roles/types";
|
||||
import { TOrgRole } from "../roles/types";
|
||||
import { IdentityAuthMethod } from "./enums";
|
||||
|
||||
export type IdentityTrustedIp = {
|
||||
@@ -29,9 +29,18 @@ export type IdentityMembershipOrg = {
|
||||
export type IdentityMembership = {
|
||||
id: string;
|
||||
identity: Identity;
|
||||
organization: string;
|
||||
role: "admin" | "member" | "viewer" | "no-access" | "custom";
|
||||
customRole?: TProjectRole;
|
||||
roles: {
|
||||
id: string;
|
||||
role: "owner" | "admin" | "member" | "no-access" | "custom";
|
||||
customRoleId: string;
|
||||
customRoleName: string;
|
||||
customRoleSlug: string;
|
||||
isTemporary: boolean;
|
||||
temporaryMode: string | null;
|
||||
temporaryRange: string | null;
|
||||
temporaryAccessStartTime: string | null;
|
||||
temporaryAccessEndTime: string | null;
|
||||
}[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
@@ -64,7 +64,33 @@ export type TProjectMembership = {
|
||||
roleId: string;
|
||||
};
|
||||
|
||||
export type TWorkspaceUser = OrgUser;
|
||||
export type TWorkspaceUser = {
|
||||
id: string;
|
||||
user: {
|
||||
email: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
id: string;
|
||||
publicKey: string;
|
||||
};
|
||||
inviteEmail: string;
|
||||
organization: string;
|
||||
roles: {
|
||||
id: string;
|
||||
role: "owner" | "admin" | "member" | "no-access" | "custom";
|
||||
customRoleId: string;
|
||||
customRoleName: string;
|
||||
customRoleSlug: string;
|
||||
isTemporary: boolean;
|
||||
temporaryMode: string | null;
|
||||
temporaryRange: string | null;
|
||||
temporaryAccessStartTime: string | null;
|
||||
temporaryAccessEndTime: string | null;
|
||||
}[];
|
||||
status: "invited" | "accepted" | "verified" | "completed";
|
||||
deniedPermissions: any[];
|
||||
};
|
||||
|
||||
export type AddUserToWsDTOE2EE = {
|
||||
workspaceId: string;
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
RenameWorkspaceDTO,
|
||||
TGetUpgradeProjectStatusDTO,
|
||||
ToggleAutoCapitalizationDTO,
|
||||
TUpdateWorkspaceIdentityRoleDTO,
|
||||
TUpdateWorkspaceUserRoleDTO,
|
||||
UpdateEnvironmentDTO,
|
||||
Workspace
|
||||
} from "./types";
|
||||
@@ -340,27 +342,19 @@ export const useDeleteUserFromWorkspace = () => {
|
||||
export const useUpdateUserWorkspaceRole = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
membershipId,
|
||||
role,
|
||||
workspaceId
|
||||
}: {
|
||||
membershipId: string;
|
||||
role: string;
|
||||
workspaceId: string;
|
||||
}) => {
|
||||
mutationFn: async ({ membershipId, roles, workspaceId }: TUpdateWorkspaceUserRoleDTO) => {
|
||||
const {
|
||||
data: { membership }
|
||||
} = await apiRequest.patch<{ membership: { projectId: string } }>(
|
||||
`/api/v1/workspace/${workspaceId}/memberships/${membershipId}`,
|
||||
{
|
||||
role
|
||||
roles
|
||||
}
|
||||
);
|
||||
return membership;
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(res.projectId));
|
||||
onSuccess: (_, { workspaceId }) => {
|
||||
queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(workspaceId));
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -400,18 +394,14 @@ export const useUpdateIdentityWorkspaceRole = () => {
|
||||
mutationFn: async ({
|
||||
identityId,
|
||||
workspaceId,
|
||||
role
|
||||
}: {
|
||||
identityId: string;
|
||||
workspaceId: string;
|
||||
role?: string;
|
||||
}) => {
|
||||
roles
|
||||
}:TUpdateWorkspaceIdentityRoleDTO)=> {
|
||||
const {
|
||||
data: { identityMembership }
|
||||
} = await apiRequest.patch(
|
||||
`/api/v2/workspace/${workspaceId}/identity-memberships/${identityId}`,
|
||||
{
|
||||
role
|
||||
roles
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ export enum ProjectVersion {
|
||||
V2 = 2
|
||||
}
|
||||
|
||||
export enum ProjectUserMembershipTemporaryMode {
|
||||
Relative = "relative"
|
||||
}
|
||||
|
||||
export type Workspace = {
|
||||
__v: number;
|
||||
id: string;
|
||||
@@ -72,3 +76,39 @@ export type UpdateEnvironmentDTO = {
|
||||
};
|
||||
|
||||
export type DeleteEnvironmentDTO = { workspaceId: string; id: string };
|
||||
|
||||
export type TUpdateWorkspaceUserRoleDTO = {
|
||||
membershipId: string;
|
||||
workspaceId: string;
|
||||
roles: (
|
||||
| {
|
||||
role: string;
|
||||
isTemporary?: false;
|
||||
}
|
||||
| {
|
||||
role: string;
|
||||
isTemporary: true;
|
||||
temporaryMode: ProjectUserMembershipTemporaryMode;
|
||||
temporaryRange: string;
|
||||
temporaryAccessStartTime: string;
|
||||
}
|
||||
)[];
|
||||
};
|
||||
|
||||
export type TUpdateWorkspaceIdentityRoleDTO = {
|
||||
identityId: string;
|
||||
workspaceId: string;
|
||||
roles: (
|
||||
| {
|
||||
role: string;
|
||||
isTemporary?: false;
|
||||
}
|
||||
| {
|
||||
role: string;
|
||||
isTemporary: true;
|
||||
temporaryMode: ProjectUserMembershipTemporaryMode;
|
||||
temporaryRange: string;
|
||||
temporaryAccessStartTime: string;
|
||||
}
|
||||
)[];
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
@@ -99,7 +100,7 @@ const supportOptions = [
|
||||
];
|
||||
|
||||
const formSchema = yup.object({
|
||||
name: yup.string().required().label("Project Name").trim(),
|
||||
name: yup.string().required().label("Project Name").trim().max(64, "Too long, maximum length is 64 characters"),
|
||||
addMembers: yup.bool().required().label("Add Members")
|
||||
});
|
||||
|
||||
@@ -273,13 +274,16 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
||||
</Link>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="data-[state=open]:bg-mineshaft-600">
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
className="max-w-[160px] data-[state=open]:bg-mineshaft-600"
|
||||
>
|
||||
<div className="mr-auto flex items-center rounded-md py-1.5 pl-1.5 pr-2 hover:bg-mineshaft-600">
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-primary text-sm">
|
||||
<div className="flex h-5 w-5 min-w-[20px] items-center justify-center rounded-md bg-primary text-sm">
|
||||
{currentOrg?.name.charAt(0)}
|
||||
</div>
|
||||
<div
|
||||
className="overflow-hidden text-ellipsis pl-2 text-sm text-mineshaft-100"
|
||||
className="overflow-hidden truncate text-ellipsis pl-2 text-sm text-mineshaft-100"
|
||||
style={{ maxWidth: "140px" }}
|
||||
>
|
||||
{currentOrg?.name}
|
||||
@@ -323,7 +327,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex w-full max-w-[150px] items-center justify-between truncate">
|
||||
{org.name}
|
||||
</div>
|
||||
</Button>
|
||||
@@ -422,7 +426,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
||||
<Select
|
||||
defaultValue={currentWorkspace?.id}
|
||||
value={currentWorkspace?.id}
|
||||
className="w-full truncate bg-mineshaft-600 py-2.5 font-medium"
|
||||
className="w-full [&>*:first-child]:truncate bg-mineshaft-600 py-2.5 font-medium"
|
||||
onValueChange={(value) => {
|
||||
router.push(`/project/${value}/secrets/overview`);
|
||||
localStorage.setItem("projectData.id", value);
|
||||
@@ -437,7 +441,10 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
||||
<SelectItem
|
||||
key={`ws-layout-list-${id}`}
|
||||
value={id}
|
||||
className={`${currentWorkspace?.id === id && "bg-mineshaft-600"}`}
|
||||
className={twMerge(
|
||||
currentWorkspace?.id === id && "bg-mineshaft-600",
|
||||
"truncate"
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</SelectItem>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Sorts an array of items into groups. The return value is a map where the keys are
|
||||
* the group ids the given getGroupId function produced and the value is an array of
|
||||
* each item in that group.
|
||||
*/
|
||||
export const groupBy = <T, Key extends string | number | symbol>(
|
||||
array: readonly T[],
|
||||
getGroupId: (item: T) => Key
|
||||
): Record<Key, T[]> =>
|
||||
array.reduce((acc, item) => {
|
||||
const groupId = getGroupId(item);
|
||||
if (!acc[groupId]) acc[groupId] = [];
|
||||
acc[groupId].push(item);
|
||||
return acc;
|
||||
}, {} as Record<Key, T[]>);
|
||||
@@ -453,7 +453,7 @@ const LearningItemSquare = ({
|
||||
};
|
||||
|
||||
const formSchema = yup.object({
|
||||
name: yup.string().required().label("Project Name").trim(),
|
||||
name: yup.string().required().label("Project Name").trim().max(64, "Too long, maximum length is 64 characters"),
|
||||
addMembers: yup.bool().required().label("Add Members")
|
||||
});
|
||||
|
||||
@@ -633,7 +633,7 @@ const OrganizationPage = withPermission(
|
||||
key={workspace.id}
|
||||
className="min-w-72 flex h-40 flex-col justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4"
|
||||
>
|
||||
<div className="mt-0 text-lg text-mineshaft-100">{workspace.name}</div>
|
||||
<div className="mt-0 truncate text-lg text-mineshaft-100">{workspace.name}</div>
|
||||
<div className="mt-0 pb-6 text-sm text-mineshaft-300">
|
||||
{workspace.environments?.length || 0} environments
|
||||
</div>
|
||||
|
||||
@@ -5,12 +5,7 @@ import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { withProjectPermission } from "@app/hoc";
|
||||
|
||||
import {
|
||||
IdentityTab,
|
||||
MemberListTab,
|
||||
ProjectRoleListTab,
|
||||
ServiceTokenTab
|
||||
} from "./components";
|
||||
import { IdentityTab, MemberListTab, ProjectRoleListTab, ServiceTokenTab } from "./components";
|
||||
|
||||
enum TabSections {
|
||||
Member = "members",
|
||||
@@ -23,19 +18,14 @@ export const MembersPage = withProjectPermission(
|
||||
() => {
|
||||
return (
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
<div className="mb-6 w-full py-6 px-6 max-w-7xl mx-auto">
|
||||
<p className="mr-4 mb-4 text-3xl font-semibold text-white">
|
||||
Project Access Control
|
||||
</p>
|
||||
<div className="mx-auto mb-6 w-full max-w-7xl py-6 px-6">
|
||||
<p className="mr-4 mb-4 text-3xl font-semibold text-white">Project Access Control</p>
|
||||
<Tabs defaultValue={TabSections.Member}>
|
||||
<TabList>
|
||||
<Tab value={TabSections.Member}>People</Tab>
|
||||
<Tab value={TabSections.Identities}>
|
||||
<div className="flex items-center">
|
||||
<p>Machine Identities</p>
|
||||
<div className="ml-2 rounded-md text-yellow text-sm inline-block bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] opacity-80 hover:opacity-100 cursor-default">
|
||||
New
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab value={TabSections.ServiceTokens}>Service Tokens</Tab>
|
||||
|
||||
+459
@@ -0,0 +1,459 @@
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faCheck, faClock, faEdit, faSearch } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
IconButton,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Spinner,
|
||||
Tag,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useGetProjectRoles, useUpdateIdentityWorkspaceRole } from "@app/hooks/api";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { TWorkspaceUser } from "@app/hooks/api/types";
|
||||
import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types";
|
||||
import { groupBy } from "@app/lib/fn/array";
|
||||
|
||||
const temporaryRoleFormSchema = z.object({
|
||||
temporaryRange: z.string().min(1, "Required")
|
||||
});
|
||||
|
||||
type TTemporaryRoleFormSchema = z.infer<typeof temporaryRoleFormSchema>;
|
||||
|
||||
type TTemporaryRoleFormProps = {
|
||||
temporaryConfig?: {
|
||||
isTemporary?: boolean;
|
||||
temporaryAccessEndTime?: string | null;
|
||||
temporaryAccessStartTime?: string | null;
|
||||
temporaryRange?: string | null;
|
||||
};
|
||||
onSetTemporary: (data: { temporaryRange: string; temporaryAccessStartTime?: string }) => void;
|
||||
onRemoveTemporary: () => void;
|
||||
};
|
||||
|
||||
const IdentityTemporaryRoleForm = ({
|
||||
temporaryConfig: defaultValues = {},
|
||||
onSetTemporary,
|
||||
onRemoveTemporary
|
||||
}: TTemporaryRoleFormProps) => {
|
||||
const { popUp, handlePopUpToggle } = usePopUp(["setTempRole"] as const);
|
||||
const { control, handleSubmit } = useForm<TTemporaryRoleFormSchema>({
|
||||
resolver: zodResolver(temporaryRoleFormSchema),
|
||||
values: {
|
||||
temporaryRange: defaultValues.temporaryRange || "1h"
|
||||
}
|
||||
});
|
||||
const isTemporaryFieldValue = defaultValues.isTemporary;
|
||||
const isExpired =
|
||||
isTemporaryFieldValue && new Date() > new Date(defaultValues.temporaryAccessEndTime || "");
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={popUp.setTempRole.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("setTempRole", isOpen);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<IconButton ariaLabel="role-temp" variant="plain" size="md">
|
||||
<Tooltip content={isExpired ? "Access Expired" : "Grant Temporary Access"}>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(
|
||||
isTemporaryFieldValue && "text-primary",
|
||||
isExpired && "text-red-600"
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
arrowClassName="fill-gray-600"
|
||||
side="right"
|
||||
sideOffset={12}
|
||||
hideCloseBtn
|
||||
className="border border-gray-600 pt-4"
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
|
||||
Set Role Temporarily
|
||||
</div>
|
||||
{isExpired && <Tag colorSchema="red">Expired</Tag>}
|
||||
<Controller
|
||||
control={control}
|
||||
name="temporaryRange"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Validity"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
helperText={
|
||||
<span>
|
||||
1m, 2h, 3d.{" "}
|
||||
<a
|
||||
href="https://github.com/vercel/ms?tab=readme-ov-file#examples"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-700"
|
||||
>
|
||||
More
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
{isTemporaryFieldValue && (
|
||||
<Button
|
||||
size="xs"
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
handleSubmit(({ temporaryRange }) => {
|
||||
onSetTemporary({
|
||||
temporaryRange,
|
||||
temporaryAccessStartTime: new Date().toISOString()
|
||||
});
|
||||
handlePopUpToggle("setTempRole");
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
)}
|
||||
{!isTemporaryFieldValue ? (
|
||||
<Button
|
||||
size="xs"
|
||||
type="submit"
|
||||
onClick={() =>
|
||||
handleSubmit(({ temporaryRange }) => {
|
||||
onSetTemporary({
|
||||
temporaryRange,
|
||||
temporaryAccessStartTime:
|
||||
defaultValues.temporaryAccessStartTime || new Date().toISOString()
|
||||
});
|
||||
handlePopUpToggle("setTempRole");
|
||||
})()
|
||||
}
|
||||
>
|
||||
Grant access
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
onClick={() => {
|
||||
onRemoveTemporary();
|
||||
handlePopUpToggle("setTempRole");
|
||||
}}
|
||||
>
|
||||
Revoke Access
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const formSchema = z.record(
|
||||
z.object({
|
||||
isChecked: z.boolean().optional(),
|
||||
temporaryAccess: z.union([
|
||||
z.object({
|
||||
isTemporary: z.literal(true),
|
||||
temporaryRange: z.string().min(1),
|
||||
temporaryAccessStartTime: z.string().datetime(),
|
||||
temporaryAccessEndTime: z.string().datetime().nullable().optional()
|
||||
}),
|
||||
z.boolean()
|
||||
])
|
||||
})
|
||||
);
|
||||
type TForm = z.infer<typeof formSchema>;
|
||||
|
||||
export type TMemberRolesProp = {
|
||||
disableEdit?: boolean;
|
||||
identityId: string;
|
||||
roles: TWorkspaceUser["roles"];
|
||||
};
|
||||
|
||||
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
|
||||
|
||||
export const IdentityRoles = ({
|
||||
roles = [],
|
||||
disableEdit = false,
|
||||
identityId
|
||||
}: TMemberRolesProp) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const);
|
||||
const [searchRoles, setSearchRoles] = useState("");
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = useForm<TForm>({
|
||||
resolver: zodResolver(formSchema)
|
||||
});
|
||||
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
|
||||
const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
|
||||
const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role);
|
||||
|
||||
const updateIdentityWorkspaceRole = useUpdateIdentityWorkspaceRole();
|
||||
|
||||
const handleRoleUpdate = async (data: TForm) => {
|
||||
const selectedRoles = Object.keys(data)
|
||||
.filter((el) => Boolean(data[el].isChecked))
|
||||
.map((el) => {
|
||||
const isTemporary = Boolean(data[el].temporaryAccess);
|
||||
if (!isTemporary) {
|
||||
return { role: el, isTemporary: false as const };
|
||||
}
|
||||
|
||||
const tempCfg = data[el].temporaryAccess as {
|
||||
temporaryRange: string;
|
||||
temporaryAccessStartTime: string;
|
||||
};
|
||||
|
||||
return {
|
||||
role: el,
|
||||
isTemporary: true as const,
|
||||
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
|
||||
temporaryRange: tempCfg.temporaryRange,
|
||||
temporaryAccessStartTime: tempCfg.temporaryAccessStartTime
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await updateIdentityWorkspaceRole.mutateAsync({
|
||||
workspaceId,
|
||||
identityId,
|
||||
roles: selectedRoles
|
||||
});
|
||||
createNotification({ text: "Successfully updated identity role", type: "success" });
|
||||
handlePopUpToggle("editRole");
|
||||
setSearchRoles("");
|
||||
} catch (err) {
|
||||
createNotification({ text: "Failed to update identity role", type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const formatRoleName = (role: string, customRoleName?: string) => {
|
||||
if (role === ProjectMembershipRole.Custom) return customRoleName;
|
||||
if (role === ProjectMembershipRole.Member) return "Developer";
|
||||
return role;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
{roles
|
||||
.slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
|
||||
.map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
|
||||
const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string));
|
||||
return (
|
||||
<Tag key={id} className="capitalize">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>{formatRoleName(role, customRoleName)}</div>
|
||||
{isTemporary && (
|
||||
<div>
|
||||
<Tooltip content={isExpired ? "Expired Temporary Access" : "Temporary Access"}>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(isExpired && "text-red-600")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
{roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<Tag>+{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE}</Tag>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="border border-gray-700 bg-mineshaft-800 p-4">
|
||||
{roles
|
||||
.slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
|
||||
.map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
|
||||
const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string));
|
||||
return (
|
||||
<Tag key={id} className="capitalize">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>{formatRoleName(role, customRoleName)}</div>
|
||||
{isTemporary && (
|
||||
<div>
|
||||
<Tooltip
|
||||
content={isExpired ? "Expired Temporary Access" : "Temporary Access"}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(
|
||||
new Date() > new Date(temporaryAccessEndTime as string) &&
|
||||
"text-red-600"
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
})}{" "}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
<div>
|
||||
<Popover
|
||||
open={popUp.editRole.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("editRole", isOpen);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
{!disableEdit && (
|
||||
<PopoverTrigger>
|
||||
<IconButton size="sm" variant="plain" ariaLabel="update">
|
||||
<FontAwesomeIcon icon={faEdit} />
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
)}
|
||||
<PopoverContent hideCloseBtn className="pt-4">
|
||||
{isRolesLoading ? (
|
||||
<div className="flex h-8 w-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(handleRoleUpdate)} id="role-update-form">
|
||||
<div className="thin-scrollbar max-h-80 space-y-4 overflow-y-auto">
|
||||
{projectRoles
|
||||
?.filter(
|
||||
({ name, slug }) =>
|
||||
name.toLowerCase().includes(searchRoles.toLowerCase()) ||
|
||||
slug.toLowerCase().includes(searchRoles.toLowerCase())
|
||||
)
|
||||
?.map(({ id, name, slug }) => {
|
||||
const userProjectRoleDetails = userRolesGroupBySlug?.[slug]?.[0];
|
||||
|
||||
return (
|
||||
<div key={id} className="flex items-center space-x-4">
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue={Boolean(userProjectRoleDetails?.id)}
|
||||
name={`${slug}.isChecked`}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
id={slug}
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(isChecked) => {
|
||||
field.onChange(isChecked);
|
||||
setValue(`${slug}.temporaryAccess`, false);
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Checkbox>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`${slug}.temporaryAccess`}
|
||||
defaultValue={
|
||||
userProjectRoleDetails?.isTemporary
|
||||
? {
|
||||
isTemporary: true,
|
||||
temporaryAccessStartTime:
|
||||
userProjectRoleDetails.temporaryAccessStartTime as string,
|
||||
temporaryRange:
|
||||
userProjectRoleDetails.temporaryRange as string,
|
||||
temporaryAccessEndTime:
|
||||
userProjectRoleDetails.temporaryAccessEndTime
|
||||
}
|
||||
: false
|
||||
}
|
||||
render={({ field }) => (
|
||||
<IdentityTemporaryRoleForm
|
||||
temporaryConfig={
|
||||
typeof field.value === "boolean"
|
||||
? { isTemporary: field.value }
|
||||
: field.value
|
||||
}
|
||||
onSetTemporary={(data) => {
|
||||
setValue(`${slug}.isChecked`, true, { shouldDirty: true });
|
||||
console.log(data);
|
||||
field.onChange({ isTemporary: true, ...data });
|
||||
}}
|
||||
onRemoveTemporary={() => {
|
||||
setValue(`${slug}.isChecked`, false, { shouldDirty: true });
|
||||
field.onChange(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center space-x-2 border-t border-t-gray-700 pt-3">
|
||||
<div>
|
||||
<Input
|
||||
className="w-full p-1.5 pl-8"
|
||||
size="xs"
|
||||
value={searchRoles}
|
||||
onChange={(el) => setSearchRoles(el.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faSearch} />}
|
||||
placeholder="Search roles.."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
size="xs"
|
||||
type="submit"
|
||||
form="role-update-form"
|
||||
leftIcon={<FontAwesomeIcon icon={faCheck} />}
|
||||
isDisabled={!isDirty || isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+7
-61
@@ -2,13 +2,10 @@ import { faServer, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format } from "date-fns";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Select,
|
||||
SelectItem,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
@@ -19,13 +16,11 @@ import {
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import {
|
||||
useGetProjectRoles,
|
||||
useGetWorkspaceIdentityMemberships,
|
||||
useUpdateIdentityWorkspaceRole
|
||||
} from "@app/hooks/api";
|
||||
import { useGetWorkspaceIdentityMemberships } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
import { IdentityRoles } from "./IdentityRoles";
|
||||
|
||||
type Props = {
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["deleteIdentity", "identity"]>,
|
||||
@@ -37,39 +32,9 @@ type Props = {
|
||||
};
|
||||
|
||||
export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
const { data, isLoading } = useGetWorkspaceIdentityMemberships(currentWorkspace?.id || "");
|
||||
|
||||
const { data: roles } = useGetProjectRoles(workspaceId);
|
||||
|
||||
const { mutateAsync: updateMutateAsync } = useUpdateIdentityWorkspaceRole();
|
||||
|
||||
const handleChangeRole = async ({ identityId, role }: { identityId: string; role: string }) => {
|
||||
try {
|
||||
await updateMutateAsync({
|
||||
identityId,
|
||||
workspaceId,
|
||||
role
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully updated identity role",
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const error = err as any;
|
||||
const text = error?.response?.data?.message ?? "Failed to update identity role";
|
||||
|
||||
createNotification({
|
||||
text,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
@@ -86,7 +51,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
{!isLoading &&
|
||||
data &&
|
||||
data.length > 0 &&
|
||||
data.map(({ identity: { id, name }, role, customRole, createdAt }) => {
|
||||
data.map(({ identity: { id, name }, roles, createdAt }) => {
|
||||
return (
|
||||
<Tr className="h-10" key={`st-v3-${id}`}>
|
||||
<Td>{name}</Td>
|
||||
@@ -95,28 +60,9 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.Identity}
|
||||
>
|
||||
{(isAllowed) => {
|
||||
return (
|
||||
<Select
|
||||
value={role === "custom" ? (customRole?.slug as string) : role}
|
||||
isDisabled={!isAllowed}
|
||||
className="w-40 bg-mineshaft-600"
|
||||
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
|
||||
onValueChange={(selectedRole) =>
|
||||
handleChangeRole({
|
||||
identityId: id,
|
||||
role: selectedRole
|
||||
})
|
||||
}
|
||||
>
|
||||
{(roles || []).map(({ slug, name: roleName }) => (
|
||||
<SelectItem value={slug} key={`owner-option-${slug}`}>
|
||||
{roleName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}}
|
||||
{(isAllowed) => (
|
||||
<IdentityRoles roles={roles} disableEdit={!isAllowed} identityId={id} />
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</Td>
|
||||
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
|
||||
|
||||
+51
-114
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Link from "next/link";
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useOrganization,
|
||||
useSubscription,
|
||||
useUser,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
@@ -44,14 +43,13 @@ import {
|
||||
useAddUserToWsNonE2EE,
|
||||
useDeleteUserFromWorkspace,
|
||||
useGetOrgUsers,
|
||||
useGetProjectRoles,
|
||||
useGetUserWsKey,
|
||||
useGetWorkspaceUsers,
|
||||
useUpdateUserWorkspaceRole
|
||||
useGetWorkspaceUsers
|
||||
} from "@app/hooks/api";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { ProjectVersion } from "@app/hooks/api/workspace/types";
|
||||
|
||||
import { MemberRoles } from "./MemberRoles";
|
||||
|
||||
const addMemberFormSchema = z.object({
|
||||
orgMembershipId: z.string().trim()
|
||||
});
|
||||
@@ -60,7 +58,6 @@ type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
|
||||
|
||||
export const MemberListTab = () => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { subscription } = useSubscription();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { currentOrg } = useOrganization();
|
||||
@@ -71,8 +68,6 @@ export const MemberListTab = () => {
|
||||
const orgId = currentOrg?.id || "";
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
|
||||
const { data: roles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
|
||||
|
||||
const { data: wsKey } = useGetUserWsKey(workspaceId);
|
||||
const { data: members, isLoading: isMembersLoading } = useGetWorkspaceUsers(workspaceId);
|
||||
const { data: orgUsers } = useGetOrgUsers(orgId);
|
||||
@@ -95,7 +90,6 @@ export const MemberListTab = () => {
|
||||
const { mutateAsync: addUserToWorkspace } = useAddUserToWsE2EE();
|
||||
const { mutateAsync: addUserToWorkspaceNonE2EE } = useAddUserToWsNonE2EE();
|
||||
const { mutateAsync: removeUserFromWorkspace } = useDeleteUserFromWorkspace();
|
||||
const { mutateAsync: updateUserWorkspaceRole } = useUpdateUserWorkspaceRole();
|
||||
|
||||
const onAddMember = async ({ orgMembershipId }: TAddMemberForm) => {
|
||||
if (!currentWorkspace) return;
|
||||
@@ -167,47 +161,6 @@ export const MemberListTab = () => {
|
||||
handlePopUpClose("removeMember");
|
||||
};
|
||||
|
||||
const isIamOwner = useMemo(
|
||||
() => members?.find(({ user: u }) => userId === u?.id)?.role === "owner",
|
||||
[userId, members]
|
||||
);
|
||||
|
||||
const findRoleFromId = useCallback(
|
||||
(roleId: string) => {
|
||||
return (roles || []).find(({ id }) => id === roleId);
|
||||
},
|
||||
[roles]
|
||||
);
|
||||
|
||||
const onRoleChange = async (membershipId: string, role: string) => {
|
||||
if (!currentOrg?.id) return;
|
||||
|
||||
try {
|
||||
const isCustomRole = !Object.values(ProjectMembershipRole).includes(
|
||||
role as ProjectMembershipRole
|
||||
);
|
||||
|
||||
if (isCustomRole && subscription && !subscription?.rbac) {
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
description: "You can assign custom roles to members if you upgrade your Infisical plan."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await updateUserWorkspaceRole({ membershipId, role, workspaceId });
|
||||
createNotification({
|
||||
text: "Successfully updated user role",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to update user role",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const filterdUsers = useMemo(
|
||||
() =>
|
||||
members?.filter(
|
||||
@@ -231,8 +184,6 @@ export const MemberListTab = () => {
|
||||
);
|
||||
}, [orgUsers, members]);
|
||||
|
||||
const isLoading = isMembersLoading || isRolesLoading;
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
@@ -269,75 +220,61 @@ export const MemberListTab = () => {
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={4} innerKey="project-members" />}
|
||||
{!isLoading &&
|
||||
filterdUsers?.map(
|
||||
({ user: u, id: membershipId, roleId, role }) => {
|
||||
const name = u ? `${u.firstName} ${u.lastName}` : "-";
|
||||
const username = u?.username ?? "-";
|
||||
return (
|
||||
<Tr key={`membership-${membershipId}`} className="w-full">
|
||||
<Td>{name}</Td>
|
||||
<Td>{username}</Td>
|
||||
<Td>
|
||||
{isMembersLoading && <TableSkeleton columns={4} innerKey="project-members" />}
|
||||
{!isMembersLoading &&
|
||||
filterdUsers?.map(({ user: u, inviteEmail, id: membershipId, roles }) => {
|
||||
const name = u ? `${u.firstName} ${u.lastName}` : "-";
|
||||
const email = u?.email || inviteEmail;
|
||||
|
||||
return (
|
||||
<Tr key={`membership-${membershipId}`} className="w-full">
|
||||
<Td>{name}</Td>
|
||||
<Td>{email}</Td>
|
||||
<Td>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.Member}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<MemberRoles
|
||||
roles={roles}
|
||||
disableEdit={u.id === user?.id || !isAllowed}
|
||||
onOpenUpgradeModal={(description) =>
|
||||
handlePopUpOpen("upgradePlan", { description })
|
||||
}
|
||||
membershipId={membershipId}
|
||||
/>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</Td>
|
||||
<Td>
|
||||
{userId !== u?.id && (
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Member}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Select
|
||||
value={role === "custom" ? findRoleFromId(roleId)?.slug : role}
|
||||
isDisabled={userId === u?.id || !isAllowed}
|
||||
className="w-40 bg-mineshaft-600"
|
||||
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
|
||||
onValueChange={(selectedRole) =>
|
||||
onRoleChange(membershipId, selectedRole)
|
||||
}
|
||||
>
|
||||
{(roles || [])
|
||||
.filter(({ slug }) =>
|
||||
slug === "owner" ? isIamOwner || role === "owner" : true
|
||||
)
|
||||
.map(({ slug, name: roleName }) => (
|
||||
<SelectItem value={slug} key={`owner-option-${slug}`}>
|
||||
{roleName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
<IconButton
|
||||
size="lg"
|
||||
colorSchema="danger"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
className="ml-4"
|
||||
isDisabled={userId === u?.id || !isAllowed}
|
||||
onClick={() => handlePopUpOpen("removeMember", { email: u.email })}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</Td>
|
||||
<Td>
|
||||
{userId !== u?.id && (
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Member}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
size="lg"
|
||||
colorSchema="danger"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
className="ml-4"
|
||||
isDisabled={userId === u?.id || !isAllowed}
|
||||
onClick={() =>
|
||||
handlePopUpOpen("removeMember", { username: u.username })
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
)}
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
)}
|
||||
)}
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isLoading && filterdUsers?.length === 0 && (
|
||||
{!isMembersLoading && filterdUsers?.length === 0 && (
|
||||
<EmptyState title="No project members found" icon={faUsers} />
|
||||
)}
|
||||
</TableContainer>
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faCheck, faClock, faEdit, faSearch } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
IconButton,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Spinner,
|
||||
Tag,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { useSubscription, useWorkspace } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useGetProjectRoles, useUpdateUserWorkspaceRole } from "@app/hooks/api";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { TWorkspaceUser } from "@app/hooks/api/types";
|
||||
import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types";
|
||||
import { groupBy } from "@app/lib/fn/array";
|
||||
|
||||
const temporaryRoleFormSchema = z.object({
|
||||
temporaryRange: z.string().min(1, "Required")
|
||||
});
|
||||
|
||||
type TTemporaryRoleFormSchema = z.infer<typeof temporaryRoleFormSchema>;
|
||||
|
||||
type TTemporaryRoleFormProps = {
|
||||
temporaryConfig?: {
|
||||
isTemporary?: boolean;
|
||||
temporaryAccessEndTime?: string | null;
|
||||
temporaryAccessStartTime?: string | null;
|
||||
temporaryRange?: string | null;
|
||||
};
|
||||
onSetTemporary: (data: { temporaryRange: string; temporaryAccessStartTime?: string }) => void;
|
||||
onRemoveTemporary: () => void;
|
||||
};
|
||||
|
||||
const TemporaryRoleForm = ({
|
||||
temporaryConfig: defaultValues = {},
|
||||
onSetTemporary,
|
||||
onRemoveTemporary
|
||||
}: TTemporaryRoleFormProps) => {
|
||||
const { popUp, handlePopUpToggle } = usePopUp(["setTempRole"] as const);
|
||||
const { control, handleSubmit } = useForm<TTemporaryRoleFormSchema>({
|
||||
resolver: zodResolver(temporaryRoleFormSchema),
|
||||
values: {
|
||||
temporaryRange: defaultValues.temporaryRange || "1h"
|
||||
}
|
||||
});
|
||||
const isTemporaryFieldValue = defaultValues.isTemporary;
|
||||
const isExpired =
|
||||
isTemporaryFieldValue && new Date() > new Date(defaultValues.temporaryAccessEndTime || "");
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={popUp.setTempRole.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("setTempRole", isOpen);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<IconButton ariaLabel="role-temp" variant="plain" size="md">
|
||||
<Tooltip content={isExpired ? "Timed access expired" : "Grant timed access"}>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(
|
||||
isTemporaryFieldValue && "text-primary",
|
||||
isExpired && "text-red-600"
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
arrowClassName="fill-gray-600"
|
||||
side="right"
|
||||
sideOffset={12}
|
||||
hideCloseBtn
|
||||
className="border border-gray-600 pt-4"
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
|
||||
Configure timed access
|
||||
</div>
|
||||
{isExpired && <Tag colorSchema="red">Expired</Tag>}
|
||||
<Controller
|
||||
control={control}
|
||||
name="temporaryRange"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Validity"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
helperText={
|
||||
<span>
|
||||
1m, 2h, 3d.{" "}
|
||||
<a
|
||||
href="https://github.com/vercel/ms?tab=readme-ov-file#examples"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-700"
|
||||
>
|
||||
More
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
{isTemporaryFieldValue && (
|
||||
<Button
|
||||
size="xs"
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
handleSubmit(({ temporaryRange }) => {
|
||||
onSetTemporary({
|
||||
temporaryRange,
|
||||
temporaryAccessStartTime: new Date().toISOString()
|
||||
});
|
||||
handlePopUpToggle("setTempRole");
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
)}
|
||||
{!isTemporaryFieldValue ? (
|
||||
<Button
|
||||
size="xs"
|
||||
type="submit"
|
||||
onClick={() =>
|
||||
handleSubmit(({ temporaryRange }) => {
|
||||
onSetTemporary({
|
||||
temporaryRange,
|
||||
temporaryAccessStartTime:
|
||||
defaultValues.temporaryAccessStartTime || new Date().toISOString()
|
||||
});
|
||||
handlePopUpToggle("setTempRole");
|
||||
})()
|
||||
}
|
||||
>
|
||||
Grant access
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
onClick={() => {
|
||||
onRemoveTemporary();
|
||||
handlePopUpToggle("setTempRole");
|
||||
}}
|
||||
>
|
||||
Revoke Access
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const formSchema = z.record(
|
||||
z.object({
|
||||
isChecked: z.boolean().optional(),
|
||||
temporaryAccess: z.union([
|
||||
z.object({
|
||||
isTemporary: z.literal(true),
|
||||
temporaryRange: z.string().min(1),
|
||||
temporaryAccessStartTime: z.string().datetime(),
|
||||
temporaryAccessEndTime: z.string().datetime().nullable().optional()
|
||||
}),
|
||||
z.boolean()
|
||||
])
|
||||
})
|
||||
);
|
||||
type TForm = z.infer<typeof formSchema>;
|
||||
|
||||
export type TMemberRolesProp = {
|
||||
disableEdit?: boolean;
|
||||
membershipId: string;
|
||||
onOpenUpgradeModal: (description: string) => void;
|
||||
roles: TWorkspaceUser["roles"];
|
||||
};
|
||||
|
||||
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
|
||||
|
||||
export const MemberRoles = ({
|
||||
roles = [],
|
||||
disableEdit = false,
|
||||
membershipId,
|
||||
onOpenUpgradeModal
|
||||
}: TMemberRolesProp) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const);
|
||||
const [searchRoles, setSearchRoles] = useState("");
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = useForm<TForm>({
|
||||
resolver: zodResolver(formSchema)
|
||||
});
|
||||
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
|
||||
const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
|
||||
const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role);
|
||||
|
||||
const updateMembershipRole = useUpdateUserWorkspaceRole();
|
||||
|
||||
const handleRoleUpdate = async (data: TForm) => {
|
||||
const selectedRoles = Object.keys(data)
|
||||
.filter((el) => Boolean(data[el].isChecked))
|
||||
.map((el) => {
|
||||
const isTemporary = Boolean(data[el].temporaryAccess);
|
||||
if (!isTemporary) {
|
||||
return { role: el, isTemporary: false as const };
|
||||
}
|
||||
|
||||
const tempCfg = data[el].temporaryAccess as {
|
||||
temporaryRange: string;
|
||||
temporaryAccessStartTime: string;
|
||||
};
|
||||
|
||||
return {
|
||||
role: el,
|
||||
isTemporary: true as const,
|
||||
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
|
||||
temporaryRange: tempCfg.temporaryRange,
|
||||
temporaryAccessStartTime: tempCfg.temporaryAccessStartTime
|
||||
};
|
||||
});
|
||||
|
||||
const hasCustomRoleSelected = selectedRoles.some(
|
||||
(el) => !Object.values(ProjectMembershipRole).includes(el.role as ProjectMembershipRole)
|
||||
);
|
||||
|
||||
if (hasCustomRoleSelected && subscription && !subscription?.rbac) {
|
||||
onOpenUpgradeModal(
|
||||
"You can assign custom roles to members if you upgrade your Infisical plan."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateMembershipRole.mutateAsync({
|
||||
workspaceId,
|
||||
membershipId,
|
||||
roles: selectedRoles
|
||||
});
|
||||
createNotification({ text: "Successfully updated role", type: "success" });
|
||||
handlePopUpToggle("editRole");
|
||||
setSearchRoles("");
|
||||
} catch (err) {
|
||||
createNotification({ text: "Failed to update role", type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const formatRoleName = (role: string, customRoleName?: string) => {
|
||||
if (role === ProjectMembershipRole.Custom) return customRoleName;
|
||||
if (role === ProjectMembershipRole.Member) return "Developer";
|
||||
return role;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
{roles
|
||||
.slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
|
||||
.map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
|
||||
const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string));
|
||||
return (
|
||||
<Tag key={id}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>{formatRoleName(role, customRoleName)}</div>
|
||||
{isTemporary && (
|
||||
<div>
|
||||
<Tooltip content={isExpired ? "Timed role expired" : "Timed role access"}>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(isExpired && "text-red-600")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
{roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<Tag>+{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE}</Tag>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="border border-gray-700 bg-mineshaft-800 p-4">
|
||||
{roles
|
||||
.slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
|
||||
.map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
|
||||
const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string));
|
||||
return (
|
||||
<Tag key={id} className="capitalize">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>{formatRoleName(role, customRoleName)}</div>
|
||||
{isTemporary && (
|
||||
<div>
|
||||
<Tooltip
|
||||
content={isExpired ? "Access expired" : "Temporary access"}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(
|
||||
new Date() > new Date(temporaryAccessEndTime as string) &&
|
||||
"text-red-600"
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
})}{" "}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
<div>
|
||||
<Popover
|
||||
open={popUp.editRole.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("editRole", isOpen);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
{!disableEdit && (
|
||||
<PopoverTrigger>
|
||||
<IconButton size="sm" variant="plain" ariaLabel="update">
|
||||
<FontAwesomeIcon icon={faEdit} />
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
)}
|
||||
<PopoverContent hideCloseBtn className="pt-4">
|
||||
{isRolesLoading ? (
|
||||
<div className="flex h-8 w-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(handleRoleUpdate)} id="role-update-form">
|
||||
<div className="thin-scrollbar max-h-80 space-y-4 overflow-y-auto">
|
||||
{projectRoles
|
||||
?.filter(
|
||||
({ name, slug }) =>
|
||||
name.toLowerCase().includes(searchRoles.toLowerCase()) ||
|
||||
slug.toLowerCase().includes(searchRoles.toLowerCase())
|
||||
)
|
||||
?.map(({ id, name, slug }) => {
|
||||
const userProjectRoleDetails = userRolesGroupBySlug?.[slug]?.[0];
|
||||
|
||||
return (
|
||||
<div key={id} className="flex items-center space-x-4">
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue={Boolean(userProjectRoleDetails?.id)}
|
||||
name={`${slug}.isChecked`}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
id={slug}
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(isChecked) => {
|
||||
field.onChange(isChecked);
|
||||
setValue(`${slug}.temporaryAccess`, false);
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Checkbox>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`${slug}.temporaryAccess`}
|
||||
defaultValue={
|
||||
userProjectRoleDetails?.isTemporary
|
||||
? {
|
||||
isTemporary: true,
|
||||
temporaryAccessStartTime:
|
||||
userProjectRoleDetails.temporaryAccessStartTime as string,
|
||||
temporaryRange:
|
||||
userProjectRoleDetails.temporaryRange as string,
|
||||
temporaryAccessEndTime:
|
||||
userProjectRoleDetails.temporaryAccessEndTime
|
||||
}
|
||||
: false
|
||||
}
|
||||
render={({ field }) => (
|
||||
<TemporaryRoleForm
|
||||
temporaryConfig={
|
||||
typeof field.value === "boolean"
|
||||
? { isTemporary: field.value }
|
||||
: field.value
|
||||
}
|
||||
onSetTemporary={(data) => {
|
||||
setValue(`${slug}.isChecked`, true, { shouldDirty: true });
|
||||
console.log(data);
|
||||
field.onChange({ isTemporary: true, ...data });
|
||||
}}
|
||||
onRemoveTemporary={() => {
|
||||
setValue(`${slug}.isChecked`, false, { shouldDirty: true });
|
||||
field.onChange(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center space-x-2 border-t border-t-gray-700 pt-3">
|
||||
<div>
|
||||
<Input
|
||||
className="w-full p-1.5 pl-8"
|
||||
size="xs"
|
||||
value={searchRoles}
|
||||
onChange={(el) => setSearchRoles(el.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faSearch} />}
|
||||
placeholder="Search roles.."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
size="xs"
|
||||
type="submit"
|
||||
form="role-update-form"
|
||||
leftIcon={<FontAwesomeIcon icon={faCheck} />}
|
||||
isDisabled={!isDirty || isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { subject } from "@casl/ability";
|
||||
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
|
||||
import { faCheckCircle, faCircle } from "@fortawesome/free-regular-svg-icons";
|
||||
import {
|
||||
faAngleDown,
|
||||
faArrowDown,
|
||||
@@ -18,7 +18,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import NavHeader from "@app/components/navigation/NavHeader";
|
||||
import { PermissionDeniedBanner, ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
@@ -108,7 +108,11 @@ export const SecretOverviewPage = () => {
|
||||
}, [isWorkspaceLoading, workspaceId, router.isReady]);
|
||||
|
||||
const userAvailableEnvs = currentWorkspace?.environments || [];
|
||||
const [visibleEnvs, setVisisbleEnvs] = useState(userAvailableEnvs);
|
||||
const [visibleEnvs, setVisibleEnvs] = useState(userAvailableEnvs);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleEnvs(userAvailableEnvs);
|
||||
}, [userAvailableEnvs]);
|
||||
|
||||
const {
|
||||
data: secrets,
|
||||
@@ -208,9 +212,9 @@ export const SecretOverviewPage = () => {
|
||||
|
||||
const handleEnvSelect = (envId: string) => {
|
||||
if (visibleEnvs.map((env) => env.id).includes(envId)) {
|
||||
setVisisbleEnvs(visibleEnvs.filter((env) => env.id !== envId));
|
||||
setVisibleEnvs(visibleEnvs.filter((env) => env.id !== envId));
|
||||
} else {
|
||||
setVisisbleEnvs(visibleEnvs.concat(userAvailableEnvs.filter((env) => env.id === envId)));
|
||||
setVisibleEnvs(visibleEnvs.concat(userAvailableEnvs.filter((env) => env.id === envId)));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -390,41 +394,44 @@ export const SecretOverviewPage = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<FolderBreadCrumbs secretPath={secretPath} onResetSearch={handleResetSearch} />
|
||||
<div className="flex flex-row items-center justify-center space-x-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Environments"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className="mr-2 flex w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 hover:border-primary/60 hover:bg-primary/10"
|
||||
>
|
||||
<Tooltip content="Choose visible environments" className="mb-2">
|
||||
<FontAwesomeIcon icon={faList} />
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Choose visible environments</DropdownMenuLabel>
|
||||
{userAvailableEnvs.map((avaiableEnv) => {
|
||||
const { id: envId, name } = avaiableEnv;
|
||||
{userAvailableEnvs.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Environments"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className="flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 hover:border-primary/60 hover:bg-primary/10"
|
||||
>
|
||||
<Tooltip content="Choose visible environments" className="mb-2">
|
||||
<FontAwesomeIcon icon={faList} />
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Choose visible environments</DropdownMenuLabel>
|
||||
{userAvailableEnvs.map((availableEnv) => {
|
||||
const { id: envId, name } = availableEnv;
|
||||
|
||||
const isEnvSelected = visibleEnvs.map((env) => env.id).includes(envId);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleEnvSelect(envId)}
|
||||
key={envId}
|
||||
icon={
|
||||
isEnvSelected && (
|
||||
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="left"
|
||||
>
|
||||
<div className="flex items-center">{name}</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
{/* <DropdownMenuItem className="px-1.5" asChild>
|
||||
const isEnvSelected = visibleEnvs.map((env) => env.id).includes(envId);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleEnvSelect(envId)}
|
||||
key={envId}
|
||||
icon={
|
||||
isEnvSelected ? (
|
||||
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
|
||||
) : (
|
||||
<FontAwesomeIcon className="text-mineshaft-400" icon={faCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="left"
|
||||
>
|
||||
<div className="flex items-center">{name}</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
{/* <DropdownMenuItem className="px-1.5" asChild>
|
||||
<Button
|
||||
size="xs"
|
||||
className="w-full"
|
||||
@@ -436,8 +443,9 @@ export const SecretOverviewPage = () => {
|
||||
Create an environment
|
||||
</Button>
|
||||
</DropdownMenuItem> */}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<div className="w-80">
|
||||
<Input
|
||||
className="h-[2.3rem] bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
|
||||
@@ -447,62 +455,64 @@ export const SecretOverviewPage = () => {
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.Secrets, { secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handlePopUpOpen("addSecretsInAllEnvs")}
|
||||
className="h-10 rounded-r-none"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Add Secret
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<DropdownMenu
|
||||
open={popUp.misc.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("misc", isOpen)}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="add-folder-or-import"
|
||||
variant="outline_bg"
|
||||
className="rounded-l-none bg-mineshaft-600 p-3"
|
||||
>
|
||||
<FontAwesomeIcon icon={faAngleDown} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<div className="flex flex-col space-y-1 p-1.5">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.Secrets, { secretPath })}
|
||||
{userAvailableEnvs.length > 0 && (
|
||||
<div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.Secrets, { secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handlePopUpOpen("addSecretsInAllEnvs")}
|
||||
className="h-10 rounded-r-none"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faFolderPlus} />}
|
||||
onClick={() => {
|
||||
handlePopUpOpen("addFolder");
|
||||
handlePopUpClose("misc");
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
variant="outline_bg"
|
||||
className="h-10"
|
||||
isFullWidth
|
||||
>
|
||||
Add Folder
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
Add Secret
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<DropdownMenu
|
||||
open={popUp.misc.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("misc", isOpen)}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="add-folder-or-import"
|
||||
variant="outline_bg"
|
||||
className="rounded-l-none bg-mineshaft-600 p-3"
|
||||
>
|
||||
<FontAwesomeIcon icon={faAngleDown} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<div className="flex flex-col space-y-1 p-1.5">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.Secrets, { secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faFolderPlus} />}
|
||||
onClick={() => {
|
||||
handlePopUpOpen("addFolder");
|
||||
handlePopUpClose("misc");
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
variant="outline_bg"
|
||||
className="h-10"
|
||||
isFullWidth
|
||||
>
|
||||
Add Folder
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -565,14 +575,25 @@ export const SecretOverviewPage = () => {
|
||||
className="bg-mineshaft-700"
|
||||
/>
|
||||
)}
|
||||
{isTableEmpty && !isTableLoading && (
|
||||
{userAvailableEnvs.length > 0 && visibleEnvs.length === 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={visibleEnvs.length + 1}>
|
||||
<EmptyState title="Let's add some secrets" icon={faFolderBlank} iconSize="3x">
|
||||
<EmptyState title="You have no visible environments" iconSize="3x" />
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{userAvailableEnvs.length === 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={visibleEnvs.length + 1}>
|
||||
<EmptyState
|
||||
title="You have no environments, start by adding some"
|
||||
iconSize="3x"
|
||||
>
|
||||
<Link
|
||||
href={{
|
||||
pathname: "/project/[id]/secrets/[env]",
|
||||
query: { id: workspaceId, env: visibleEnvs?.[0]?.slug }
|
||||
pathname: "/project/[id]/settings",
|
||||
query: { id: workspaceId },
|
||||
hash: "environments"
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
@@ -581,13 +602,38 @@ export const SecretOverviewPage = () => {
|
||||
colorSchema="primary"
|
||||
size="md"
|
||||
>
|
||||
Go to {visibleEnvs?.[0]?.name}
|
||||
Add environments
|
||||
</Button>
|
||||
</Link>
|
||||
</EmptyState>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{isTableEmpty && !isTableLoading && visibleEnvs.length > 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={visibleEnvs.length + 1}>
|
||||
<EmptyState
|
||||
title={
|
||||
searchFilter
|
||||
? "No secret found for your search, add one now"
|
||||
: "Let's add some secrets"
|
||||
}
|
||||
icon={faFolderBlank}
|
||||
iconSize="3x"
|
||||
>
|
||||
<Button
|
||||
className="mt-4"
|
||||
variant="outline_bg"
|
||||
colorSchema="primary"
|
||||
size="md"
|
||||
onClick={() => handlePopUpOpen("addSecretsInAllEnvs")}
|
||||
>
|
||||
Add Secrets
|
||||
</Button>
|
||||
</EmptyState>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{!isTableLoading &&
|
||||
filteredFolderNames.map((folderName, index) => (
|
||||
<SecretOverviewFolderRow
|
||||
@@ -599,22 +645,19 @@ export const SecretOverviewPage = () => {
|
||||
/>
|
||||
))}
|
||||
{!isTableLoading &&
|
||||
(visibleEnvs?.length > 0 ? (
|
||||
filteredSecretNames.map((key, index) => (
|
||||
<SecretOverviewTableRow
|
||||
secretPath={secretPath}
|
||||
onSecretCreate={handleSecretCreate}
|
||||
onSecretDelete={handleSecretDelete}
|
||||
onSecretUpdate={handleSecretUpdate}
|
||||
key={`overview-${key}-${index + 1}`}
|
||||
environments={visibleEnvs}
|
||||
secretKey={key}
|
||||
getSecretByKey={getSecretByKey}
|
||||
expandableColWidth={expandableTableWidth}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<PermissionDeniedBanner />
|
||||
visibleEnvs?.length > 0 &&
|
||||
filteredSecretNames.map((key, index) => (
|
||||
<SecretOverviewTableRow
|
||||
secretPath={secretPath}
|
||||
onSecretCreate={handleSecretCreate}
|
||||
onSecretDelete={handleSecretDelete}
|
||||
onSecretUpdate={handleSecretUpdate}
|
||||
key={`overview-${key}-${index + 1}`}
|
||||
environments={visibleEnvs}
|
||||
secretKey={key}
|
||||
getSecretByKey={getSecretByKey}
|
||||
expandableColWidth={expandableTableWidth}
|
||||
/>
|
||||
))}
|
||||
</TBody>
|
||||
<TFoot>
|
||||
|
||||
+17
-7
@@ -171,7 +171,7 @@ export const CreateSecretForm = ({
|
||||
)}
|
||||
/>
|
||||
<FormLabel label="Environments" className="mb-2" />
|
||||
<div className="thin-scrollbar grid max-h-64 grid-cols-3 gap-4 overflow-auto ">
|
||||
<div className="thin-scrollbar grid max-h-64 grid-cols-3 gap-4 overflow-auto py-2">
|
||||
{environments.map((env) => {
|
||||
return (
|
||||
<Controller
|
||||
@@ -183,13 +183,23 @@ export const CreateSecretForm = ({
|
||||
isChecked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
id={`secret-input-${env.slug}`}
|
||||
className="!justify-start"
|
||||
>
|
||||
{env.name}
|
||||
{getSecretByKey(env.slug, newSecretKey) && (
|
||||
<Tooltip content="Secret exists. Will be overwritten">
|
||||
<FontAwesomeIcon icon={faWarning} className="ml-1 text-yellow-400" />
|
||||
</Tooltip>
|
||||
)}
|
||||
<span className="flex w-full flex-row items-center justify-start whitespace-pre-wrap">
|
||||
<span title={env.name} className="truncate">
|
||||
{env.name}
|
||||
</span>
|
||||
<span>
|
||||
{getSecretByKey(env.slug, newSecretKey) && (
|
||||
<Tooltip
|
||||
className="max-w-[150px]"
|
||||
content="Secret already exists, and it will be overwritten"
|
||||
>
|
||||
<FontAwesomeIcon icon={faWarning} className="ml-1 text-yellow-400" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</Checkbox>
|
||||
)}
|
||||
/>
|
||||
|
||||
+7
-2
@@ -148,8 +148,13 @@ export const SecretOverviewTableRow = ({
|
||||
key={`secret-expanded-${slug}-${secretKey}`}
|
||||
className="hover:bg-mineshaft-700"
|
||||
>
|
||||
<td className="flex" style={{ padding: "0.25rem 1rem" }}>
|
||||
<div className="flex h-8 items-center">{name}</div>
|
||||
<td
|
||||
className="flex h-full items-center"
|
||||
style={{ padding: "0.25rem 1rem" }}
|
||||
>
|
||||
<div title={name} className="flex h-8 w-[8rem] items-center ">
|
||||
<span className="truncate">{name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="col-span-2 h-8 w-full">
|
||||
<SecretEditRow
|
||||
|
||||
@@ -351,7 +351,7 @@ export const SecretRotationPage = withProjectPermission(
|
||||
{!isRotationProviderLoading &&
|
||||
secretRotationProviders?.providers.map((provider) => (
|
||||
<div
|
||||
className="group relative flex h-32 cursor-pointer flex-row items-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4 hover:border-primary/40 hover:bg-primary/10"
|
||||
className="group relative flex h-32 cursor-pointer flex-row items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4 hover:border-primary/40 hover:bg-primary/10"
|
||||
key={`infisical-rotation-provider-${provider.name}`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
@@ -362,8 +362,8 @@ export const SecretRotationPage = withProjectPermission(
|
||||
>
|
||||
<img
|
||||
src={`/images/secretRotation/${provider.image}`}
|
||||
height={70}
|
||||
width={70}
|
||||
className="max-h-16"
|
||||
style={{ maxWidth: "6rem" }}
|
||||
alt="rotation provider logo"
|
||||
/>
|
||||
<div className="ml-4 max-w-xs text-xl font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@a
|
||||
import { useUpdateOrg } from "@app/hooks/api";
|
||||
|
||||
const formSchema = yup.object({
|
||||
name: yup.string().required().label("Organization Name"),
|
||||
name: yup.string().required().label("Organization Name").max(64, "Too long, maximum length is 64 characters"),
|
||||
slug: yup
|
||||
.string()
|
||||
.matches(/^[a-zA-Z0-9-]+$/, "Name must only contain alphanumeric characters or hyphens")
|
||||
|
||||
+4
-1
@@ -63,7 +63,10 @@ export const EnvironmentSection = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div
|
||||
id="environments"
|
||||
className="mb-6 scroll-m-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
|
||||
>
|
||||
<div className="mb-8 flex justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Environments</p>
|
||||
<div>
|
||||
|
||||
+19
-11
@@ -12,7 +12,7 @@ import { useRenameWorkspace } from "@app/hooks/api";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
|
||||
const formSchema = yup.object({
|
||||
name: yup.string().required().label("Project Name")
|
||||
name: yup.string().required().label("Project Name").max(64, "Too long, maximum length is 64 characters"),
|
||||
});
|
||||
|
||||
type FormData = yup.InferType<typeof formSchema>;
|
||||
@@ -78,18 +78,26 @@ export const ProjectNameChangeSection = () => {
|
||||
</CopyButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-md">
|
||||
<Controller
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input placeholder="Project name" {...field} className="bg-mineshaft-800" />
|
||||
</FormControl>
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Workspace}>
|
||||
{(isAllowed) => (
|
||||
<Controller
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input
|
||||
placeholder="Project name"
|
||||
{...field}
|
||||
className="bg-mineshaft-800"
|
||||
isDisabled={!isAllowed}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
control={control}
|
||||
name="name"
|
||||
/>
|
||||
)}
|
||||
control={control}
|
||||
name="name"
|
||||
/>
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Workspace}>
|
||||
{(isAllowed) => (
|
||||
|
||||
@@ -244,6 +244,7 @@ export const UserInfoSSOStep = ({
|
||||
onChange={(e) => setOrganizationName(e.target.value)}
|
||||
isRequired
|
||||
className="h-12"
|
||||
maxLength={64}
|
||||
disabled
|
||||
/>
|
||||
{organizationNameError && (
|
||||
|
||||
Reference in New Issue
Block a user