mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-06 19:28:16 +00:00
feat(ui): updated ui with new role form for users
This commit is contained in:
@@ -3,5 +3,5 @@ export {
|
|||||||
useDeleteProjectUserAdditionalPrivilege,
|
useDeleteProjectUserAdditionalPrivilege,
|
||||||
useUpdateProjectUserAdditionalPrivilege
|
useUpdateProjectUserAdditionalPrivilege
|
||||||
} from "./mutation";
|
} from "./mutation";
|
||||||
export { useGetProjectUserPrivilegeDetails } from "./queries";
|
export { useGetProjectUserPrivilegeDetails, useListProjectUserPrivileges } from "./queries";
|
||||||
export type { TProjectUserPrivilege } from "./types";
|
export type { TProjectUserPrivilege } from "./types";
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|||||||
|
|
||||||
import { apiRequest } from "@app/config/request";
|
import { apiRequest } from "@app/config/request";
|
||||||
|
|
||||||
import { workspaceKeys } from "../workspace/queries";
|
import { projectUserPrivilegeKeys } from "./queries";
|
||||||
import {
|
import {
|
||||||
TCreateProjectUserPrivilegeDTO,
|
TCreateProjectUserPrivilegeDTO,
|
||||||
TDeleteProjectUserPrivilegeDTO,
|
TDeleteProjectUserPrivilegeDTO,
|
||||||
@@ -22,8 +22,8 @@ export const useCreateProjectUserAdditionalPrivilege = () => {
|
|||||||
});
|
});
|
||||||
return data.privilege;
|
return data.privilege;
|
||||||
},
|
},
|
||||||
onSuccess: (_, { workspaceId }) => {
|
onSuccess: (_, { projectMembershipId }) => {
|
||||||
queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(workspaceId));
|
queryClient.invalidateQueries(projectUserPrivilegeKeys.list(projectMembershipId));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -39,8 +39,8 @@ export const useUpdateProjectUserAdditionalPrivilege = () => {
|
|||||||
);
|
);
|
||||||
return data.privilege;
|
return data.privilege;
|
||||||
},
|
},
|
||||||
onSuccess: (_, { workspaceId }) => {
|
onSuccess: (_, { projectMembershipId }) => {
|
||||||
queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(workspaceId));
|
queryClient.invalidateQueries(projectUserPrivilegeKeys.list(projectMembershipId));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -55,8 +55,8 @@ export const useDeleteProjectUserAdditionalPrivilege = () => {
|
|||||||
);
|
);
|
||||||
return data.privilege;
|
return data.privilege;
|
||||||
},
|
},
|
||||||
onSuccess: (_, { workspaceId }) => {
|
onSuccess: (_, { projectMembershipId }) => {
|
||||||
queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(workspaceId));
|
queryClient.invalidateQueries(projectUserPrivilegeKeys.list(projectMembershipId));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import { TProjectPermission } from "../roles/types";
|
|||||||
import { TProjectUserPrivilege } from "./types";
|
import { TProjectUserPrivilege } from "./types";
|
||||||
|
|
||||||
export const projectUserPrivilegeKeys = {
|
export const projectUserPrivilegeKeys = {
|
||||||
details: (privilegeId: string) => ["project-user-privilege", { privilegeId }] as const
|
details: (privilegeId: string) => ["project-user-privilege", { privilegeId }] as const,
|
||||||
|
list: (projectMembershipId: string) =>
|
||||||
|
["project-user-privileges", { projectMembershipId }] as const
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchProjectUserPrivilegeDetails = async (privilegeId: string) => {
|
const fetchProjectUserPrivilegeDetails = async (privilegeId: string) => {
|
||||||
@@ -29,3 +31,21 @@ export const useGetProjectUserPrivilegeDetails = (privilegeId: string) => {
|
|||||||
queryFn: () => fetchProjectUserPrivilegeDetails(privilegeId)
|
queryFn: () => fetchProjectUserPrivilegeDetails(privilegeId)
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useListProjectUserPrivileges = (projectMembershipId: string) => {
|
||||||
|
return useQuery({
|
||||||
|
enabled: Boolean(projectMembershipId),
|
||||||
|
queryKey: projectUserPrivilegeKeys.list(projectMembershipId),
|
||||||
|
queryFn: async () => {
|
||||||
|
const {
|
||||||
|
data: { privileges }
|
||||||
|
} = await apiRequest.get<{
|
||||||
|
privileges: Array<Omit<TProjectUserPrivilege, "permissions"> & { permissions: unknown }>;
|
||||||
|
}>("/api/v1/additional-privilege/users", { params: { projectMembershipId } });
|
||||||
|
return privileges.map((el) => ({
|
||||||
|
...el,
|
||||||
|
permissions: unpackRules(el.permissions as PackRule<TProjectPermission>[])
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -7,25 +7,30 @@ export enum ProjectUserAdditionalPrivilegeTemporaryMode {
|
|||||||
export type TProjectUserPrivilege = {
|
export type TProjectUserPrivilege = {
|
||||||
projectMembershipId: string;
|
projectMembershipId: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
|
||||||
isTemporary: boolean;
|
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
description?: string | null | undefined;
|
|
||||||
temporaryMode?: string | null | undefined;
|
|
||||||
temporaryRange?: string | null | undefined;
|
|
||||||
temporaryAccessStartTime?: string | null | undefined;
|
|
||||||
temporaryAccessEndTime?: Date | null | undefined;
|
|
||||||
permissions?: TProjectPermission[];
|
permissions?: TProjectPermission[];
|
||||||
};
|
} & (
|
||||||
|
| {
|
||||||
|
isTemporary: true;
|
||||||
|
temporaryMode: string;
|
||||||
|
temporaryRange: string;
|
||||||
|
temporaryAccessStartTime: string;
|
||||||
|
temporaryAccessEndTime?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
isTemporary: false;
|
||||||
|
temporaryMode?: null;
|
||||||
|
temporaryRange?: null;
|
||||||
|
temporaryAccessStartTime?: null;
|
||||||
|
temporaryAccessEndTime?: null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export type TCreateProjectUserPrivilegeDTO = {
|
export type TCreateProjectUserPrivilegeDTO = {
|
||||||
projectMembershipId: string;
|
projectMembershipId: string;
|
||||||
slug: string;
|
slug?: string;
|
||||||
name: string;
|
|
||||||
workspaceId: string;
|
|
||||||
description?: string;
|
|
||||||
isTemporary?: boolean;
|
isTemporary?: boolean;
|
||||||
temporaryMode?: ProjectUserAdditionalPrivilegeTemporaryMode;
|
temporaryMode?: ProjectUserAdditionalPrivilegeTemporaryMode;
|
||||||
temporaryRange?: string;
|
temporaryRange?: string;
|
||||||
@@ -35,14 +40,18 @@ export type TCreateProjectUserPrivilegeDTO = {
|
|||||||
|
|
||||||
export type TUpdateProjectUserPrivlegeDTO = {
|
export type TUpdateProjectUserPrivlegeDTO = {
|
||||||
privilegeId: string;
|
privilegeId: string;
|
||||||
workspaceId: string;
|
projectMembershipId: string;
|
||||||
} & Partial<Omit<TCreateProjectUserPrivilegeDTO, "projectMembershipId">>;
|
} & Partial<Omit<TCreateProjectUserPrivilegeDTO, "projectMembershipId">>;
|
||||||
|
|
||||||
export type TDeleteProjectUserPrivilegeDTO = {
|
export type TDeleteProjectUserPrivilegeDTO = {
|
||||||
privilegeId: string;
|
privilegeId: string;
|
||||||
workspaceId: string;
|
projectMembershipId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TGetProejctUserPrivilegeDetails = {
|
export type TGetProjectUserPrivilegeDetails = {
|
||||||
privilegeId: string;
|
privilegeId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TListProjectUserPrivileges = {
|
||||||
|
projectMembershipId: string;
|
||||||
|
};
|
||||||
|
|||||||
@@ -76,30 +76,32 @@ export type TWorkspaceUser = {
|
|||||||
};
|
};
|
||||||
inviteEmail: string;
|
inviteEmail: string;
|
||||||
organization: string;
|
organization: string;
|
||||||
additionalPrivileges: {
|
roles: (
|
||||||
id: string;
|
| {
|
||||||
name: string;
|
id: string;
|
||||||
description: string | null | undefined;
|
role: "owner" | "admin" | "member" | "no-access" | "custom";
|
||||||
slug: string;
|
customRoleId: string;
|
||||||
temporaryRange: string | null | undefined;
|
customRoleName: string;
|
||||||
temporaryMode: string | null | undefined;
|
customRoleSlug: string;
|
||||||
temporaryAccessEndTime: string | null | undefined;
|
isTemporary: false;
|
||||||
temporaryAccessStartTime: string | null | undefined;
|
temporaryRange: null;
|
||||||
isTemporary: boolean;
|
temporaryMode: null;
|
||||||
createdAt: string;
|
temporaryAccessEndTime: null;
|
||||||
}[];
|
temporaryAccessStartTime: null;
|
||||||
roles: {
|
}
|
||||||
id: string;
|
| {
|
||||||
role: "owner" | "admin" | "member" | "no-access" | "custom";
|
id: string;
|
||||||
customRoleId: string;
|
role: "owner" | "admin" | "member" | "no-access" | "custom";
|
||||||
customRoleName: string;
|
customRoleId: string;
|
||||||
customRoleSlug: string;
|
customRoleName: string;
|
||||||
isTemporary: boolean;
|
customRoleSlug: string;
|
||||||
temporaryMode: string | null;
|
isTemporary: true;
|
||||||
temporaryRange: string | null;
|
temporaryRange: string;
|
||||||
temporaryAccessStartTime: string | null;
|
temporaryMode: string;
|
||||||
temporaryAccessEndTime: string | null;
|
temporaryAccessEndTime: string;
|
||||||
}[];
|
temporaryAccessStartTime: string;
|
||||||
|
}
|
||||||
|
)[];
|
||||||
status: "invited" | "accepted" | "verified" | "completed";
|
status: "invited" | "accepted" | "verified" | "completed";
|
||||||
deniedPermissions: any[];
|
deniedPermissions: any[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -315,13 +315,6 @@ export const useGetWorkspaceUsers = (workspaceId: string) => {
|
|||||||
);
|
);
|
||||||
return users;
|
return users;
|
||||||
},
|
},
|
||||||
select: (data) =>
|
|
||||||
data.map((el) => ({
|
|
||||||
...el,
|
|
||||||
additionalPrivileges: el.additionalPrivileges.sort((a, b) =>
|
|
||||||
a.createdAt.localeCompare(b.createdAt)
|
|
||||||
)
|
|
||||||
})),
|
|
||||||
enabled: true
|
enabled: true
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
-328
@@ -1,328 +0,0 @@
|
|||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { faElementor } from "@fortawesome/free-brands-svg-icons";
|
|
||||||
import {
|
|
||||||
faAnchorLock,
|
|
||||||
faArrowLeft,
|
|
||||||
faBook,
|
|
||||||
faCog,
|
|
||||||
faKey,
|
|
||||||
faLock,
|
|
||||||
faNetworkWired,
|
|
||||||
faPuzzlePiece,
|
|
||||||
faServer,
|
|
||||||
faShield,
|
|
||||||
faTags,
|
|
||||||
faUser,
|
|
||||||
faUsers
|
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
|
||||||
import guidGenerator from "@app/components/utilities/randomId";
|
|
||||||
import { Button, FormControl, Input, Spinner } from "@app/components/v2";
|
|
||||||
import { ProjectPermissionSub } from "@app/context";
|
|
||||||
import {
|
|
||||||
useCreateIdentityProjectAdditionalPrivilege,
|
|
||||||
useCreateProjectUserAdditionalPrivilege,
|
|
||||||
useGetIdentityProjectPrivilegeDetails,
|
|
||||||
useGetProjectUserPrivilegeDetails,
|
|
||||||
useUpdateIdentityProjectAdditionalPrivilege,
|
|
||||||
useUpdateProjectUserAdditionalPrivilege
|
|
||||||
} from "@app/hooks/api";
|
|
||||||
|
|
||||||
import { MultiEnvProjectPermission } from "../ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission";
|
|
||||||
import {
|
|
||||||
formRolePermission2API,
|
|
||||||
formSchema,
|
|
||||||
rolePermission2Form,
|
|
||||||
TFormSchema
|
|
||||||
} from "../ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils";
|
|
||||||
import { SecretRollbackPermission } from "../ProjectRoleListTab/components/ProjectRoleModifySection/SecretRollbackPermission";
|
|
||||||
import { SingleProjectPermission } from "../ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission";
|
|
||||||
import { WsProjectPermission } from "../ProjectRoleListTab/components/ProjectRoleModifySection/WsProjectPermission";
|
|
||||||
|
|
||||||
const SINGLE_PERMISSION_LIST = [
|
|
||||||
{
|
|
||||||
title: "Integrations",
|
|
||||||
subtitle: "Integration management control",
|
|
||||||
icon: faPuzzlePiece,
|
|
||||||
formName: "integrations"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Secret Protect policy",
|
|
||||||
subtitle: "Manage policies for secret protection for unauthorized secret changes",
|
|
||||||
icon: faShield,
|
|
||||||
formName: ProjectPermissionSub.SecretApproval
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Roles",
|
|
||||||
subtitle: "Role management control",
|
|
||||||
icon: faUsers,
|
|
||||||
formName: "role"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Project Members",
|
|
||||||
subtitle: "Project members management control",
|
|
||||||
icon: faUser,
|
|
||||||
formName: "member"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Machine identity management",
|
|
||||||
subtitle: "Add, view, update and remove (machine) identities from the project",
|
|
||||||
icon: faServer,
|
|
||||||
formName: "identity"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Webhooks",
|
|
||||||
subtitle: "Webhook management control",
|
|
||||||
icon: faAnchorLock,
|
|
||||||
formName: "webhooks"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Service Tokens",
|
|
||||||
subtitle: "Token management control",
|
|
||||||
icon: faKey,
|
|
||||||
formName: "service-tokens"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Settings",
|
|
||||||
subtitle: "Settings control",
|
|
||||||
icon: faCog,
|
|
||||||
formName: "settings"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Environments",
|
|
||||||
subtitle: "Environment management control",
|
|
||||||
icon: faElementor,
|
|
||||||
formName: "environments"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Tags",
|
|
||||||
subtitle: "Tag management control",
|
|
||||||
icon: faTags,
|
|
||||||
formName: "tags"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Audit Logs",
|
|
||||||
subtitle: "Audit log management control",
|
|
||||||
icon: faBook,
|
|
||||||
formName: "audit-logs"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "IP Allowlist",
|
|
||||||
subtitle: "IP allowlist management control",
|
|
||||||
icon: faNetworkWired,
|
|
||||||
formName: "ip-allowlist"
|
|
||||||
}
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
onGoBack: VoidFunction;
|
|
||||||
isIdentity?: boolean;
|
|
||||||
privilegeId?: string;
|
|
||||||
workspaceId: string;
|
|
||||||
// isIdentity true -> actorId is identity Id
|
|
||||||
// isIdentity false -> actorId is projectMembershipId
|
|
||||||
actorId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AdditionalPrivilegeForm = ({
|
|
||||||
onGoBack,
|
|
||||||
privilegeId,
|
|
||||||
actorId,
|
|
||||||
workspaceId,
|
|
||||||
isIdentity
|
|
||||||
}: Props) => {
|
|
||||||
|
|
||||||
const isEdit = Boolean(privilegeId);
|
|
||||||
|
|
||||||
const { data: projectUserPrivilegeDetails, isLoading: isProjectUserPrivilegeLoading } =
|
|
||||||
useGetProjectUserPrivilegeDetails(privilegeId && !isIdentity ? privilegeId : "");
|
|
||||||
|
|
||||||
const { data: identityProjectPrivilegeDetails, isLoading: isIdentityProjectPrivilegeLoading } =
|
|
||||||
useGetIdentityProjectPrivilegeDetails(isIdentity && privilegeId ? privilegeId : "");
|
|
||||||
|
|
||||||
const privileges = isIdentity ? identityProjectPrivilegeDetails : projectUserPrivilegeDetails;
|
|
||||||
const isLoading = isIdentity ? isIdentityProjectPrivilegeLoading : isProjectUserPrivilegeLoading;
|
|
||||||
|
|
||||||
const {
|
|
||||||
handleSubmit,
|
|
||||||
register,
|
|
||||||
formState: { isSubmitting, isDirty, errors },
|
|
||||||
setValue,
|
|
||||||
getValues,
|
|
||||||
control
|
|
||||||
} = useForm<TFormSchema>({
|
|
||||||
resolver: zodResolver(formSchema),
|
|
||||||
defaultValues: {
|
|
||||||
slug: `privilege-${guidGenerator().slice(0, 4).toLowerCase()}`
|
|
||||||
},
|
|
||||||
values: privileges && {
|
|
||||||
...privileges,
|
|
||||||
description: privileges.description || "",
|
|
||||||
permissions: rolePermission2Form(privileges.permissions)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const createProjectUserAdditionalPrivilege = useCreateProjectUserAdditionalPrivilege();
|
|
||||||
const updateProjectUserAdditionalPrivilege = useUpdateProjectUserAdditionalPrivilege();
|
|
||||||
|
|
||||||
const createIdentityProjectAdditionalPrivilege = useCreateIdentityProjectAdditionalPrivilege();
|
|
||||||
const updateIdentityProjectAdditionalPrivilege = useUpdateIdentityProjectAdditionalPrivilege();
|
|
||||||
|
|
||||||
const handleRoleUpdate = async (el: TFormSchema) => {
|
|
||||||
try {
|
|
||||||
if (isIdentity) {
|
|
||||||
await updateIdentityProjectAdditionalPrivilege.mutateAsync({
|
|
||||||
...el,
|
|
||||||
permissions: formRolePermission2API(el.permissions),
|
|
||||||
privilegeId: privilegeId as string,
|
|
||||||
projectId: workspaceId
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await updateProjectUserAdditionalPrivilege.mutateAsync({
|
|
||||||
...el,
|
|
||||||
permissions: formRolePermission2API(el.permissions),
|
|
||||||
privilegeId: privilegeId as string,
|
|
||||||
workspaceId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
createNotification({ type: "success", text: "Successfully update privilege" });
|
|
||||||
onGoBack();
|
|
||||||
} catch (err) {
|
|
||||||
console.log(err);
|
|
||||||
createNotification({ type: "error", text: "Failed to update privilege" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFormSubmit = async (el: TFormSchema) => {
|
|
||||||
if (isEdit) {
|
|
||||||
await handleRoleUpdate(el);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (isIdentity) {
|
|
||||||
await createIdentityProjectAdditionalPrivilege.mutateAsync({
|
|
||||||
...el,
|
|
||||||
permissions: formRolePermission2API(el.permissions),
|
|
||||||
identityId: actorId,
|
|
||||||
projectId: workspaceId
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await createProjectUserAdditionalPrivilege.mutateAsync({
|
|
||||||
...el,
|
|
||||||
permissions: formRolePermission2API(el.permissions),
|
|
||||||
projectMembershipId: actorId,
|
|
||||||
workspaceId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
createNotification({ type: "success", text: "Created new privilege" });
|
|
||||||
onGoBack();
|
|
||||||
} catch (err) {
|
|
||||||
console.log(err);
|
|
||||||
createNotification({ type: "error", text: "Failed to create privilege" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isEdit && isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center p-8">
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<h1 className="text-xl font-semibold text-mineshaft-100">
|
|
||||||
{!isEdit ? "New" : "Edit"} user additional privilege
|
|
||||||
</h1>
|
|
||||||
<Button
|
|
||||||
onClick={onGoBack}
|
|
||||||
variant="outline_bg"
|
|
||||||
leftIcon={<FontAwesomeIcon icon={faArrowLeft} />}
|
|
||||||
>
|
|
||||||
Go back
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="mb-8 text-gray-400">
|
|
||||||
Select multiple privilege that can be granted to the user
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col space-y-6">
|
|
||||||
<FormControl
|
|
||||||
label="Name"
|
|
||||||
helperText="Use descriptive names to clearly identify permissions"
|
|
||||||
isRequired
|
|
||||||
className="mb-0"
|
|
||||||
isError={Boolean(errors?.name)}
|
|
||||||
errorText={errors?.name?.message}
|
|
||||||
>
|
|
||||||
<Input {...register("name")} />
|
|
||||||
</FormControl>
|
|
||||||
<FormControl
|
|
||||||
label="Slug"
|
|
||||||
helperText="Slugs are used for API access"
|
|
||||||
isError={Boolean(errors?.slug)}
|
|
||||||
errorText={errors?.slug?.message}
|
|
||||||
>
|
|
||||||
<Input {...register("slug")} placeholder="biller" />
|
|
||||||
</FormControl>
|
|
||||||
<FormControl
|
|
||||||
label="Description"
|
|
||||||
helperText="A short description about this privilege"
|
|
||||||
isError={Boolean(errors?.description)}
|
|
||||||
errorText={errors?.description?.message}
|
|
||||||
>
|
|
||||||
<Input {...register("description")} />
|
|
||||||
</FormControl>
|
|
||||||
<div className="flex items-center justify-between border-t border-t-mineshaft-800 pt-6">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-medium">Add Privilege</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<MultiEnvProjectPermission
|
|
||||||
getValue={getValues}
|
|
||||||
control={control}
|
|
||||||
setValue={setValue}
|
|
||||||
icon={faLock}
|
|
||||||
title="Secrets"
|
|
||||||
subtitle="Create, modify and remove secrets, folders and secret imports"
|
|
||||||
formName="secrets"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div key="permission-ws">
|
|
||||||
<WsProjectPermission control={control} setValue={setValue} />
|
|
||||||
</div>
|
|
||||||
{SINGLE_PERMISSION_LIST.map(({ title, subtitle, icon, formName }) => (
|
|
||||||
<div key={`permission-${title}`}>
|
|
||||||
<SingleProjectPermission
|
|
||||||
control={control}
|
|
||||||
setValue={setValue}
|
|
||||||
icon={icon}
|
|
||||||
title={title}
|
|
||||||
subtitle={subtitle}
|
|
||||||
formName={formName}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div key="permission-secret-rollback">
|
|
||||||
<SecretRollbackPermission control={control} setValue={setValue} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-12 flex items-center space-x-4">
|
|
||||||
<Button type="submit" isDisabled={isSubmitting || !isDirty} isLoading={isSubmitting}>
|
|
||||||
{!isEdit ? "Grant Privilege" : "Save Changes"}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={onGoBack} variant="outline_bg">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-201
@@ -1,201 +0,0 @@
|
|||||||
import {
|
|
||||||
faArrowLeft,
|
|
||||||
faPencil,
|
|
||||||
faPlus,
|
|
||||||
faTrash,
|
|
||||||
faUserShield
|
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
DeleteActionModal,
|
|
||||||
EmptyState,
|
|
||||||
IconButton,
|
|
||||||
Tag,
|
|
||||||
Tooltip
|
|
||||||
} from "@app/components/v2";
|
|
||||||
import { useWorkspace } from "@app/context";
|
|
||||||
import { usePopUp } from "@app/hooks";
|
|
||||||
import {
|
|
||||||
useDeleteIdentityProjectAdditionalPrivilege,
|
|
||||||
useDeleteProjectUserAdditionalPrivilege
|
|
||||||
} from "@app/hooks/api";
|
|
||||||
import { TWorkspaceUser } from "@app/hooks/api/types";
|
|
||||||
|
|
||||||
import { AdditionalPrivilegeForm } from "./AdditionalPrivilegeForm";
|
|
||||||
import { AdditionalPrivilegeTemporaryAccess } from "./AdditionalPrivilegeTemporaryAccess";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
onGoBack: VoidFunction;
|
|
||||||
name: string;
|
|
||||||
isIdentity?: boolean;
|
|
||||||
// isIdentity id - identity id else projectMembershipId
|
|
||||||
actorId: string;
|
|
||||||
privileges: TWorkspaceUser["additionalPrivileges"];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AdditionalPrivilegeSection = ({
|
|
||||||
onGoBack,
|
|
||||||
privileges = [],
|
|
||||||
actorId,
|
|
||||||
name,
|
|
||||||
isIdentity
|
|
||||||
}: Props) => {
|
|
||||||
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
|
|
||||||
"modifyPrivilege",
|
|
||||||
"deletePrivilege"
|
|
||||||
] as const);
|
|
||||||
|
|
||||||
const { currentWorkspace } = useWorkspace();
|
|
||||||
const workspaceId = currentWorkspace?.id || "";
|
|
||||||
const deleteProjectUserAdditionalPrivilege = useDeleteProjectUserAdditionalPrivilege();
|
|
||||||
const deleteProjectIdentityAdditionalPrivilege = useDeleteIdentityProjectAdditionalPrivilege();
|
|
||||||
|
|
||||||
const onPrivilegeDelete = async (privilegeId: string) => {
|
|
||||||
try {
|
|
||||||
if (isIdentity) {
|
|
||||||
await deleteProjectIdentityAdditionalPrivilege.mutateAsync({
|
|
||||||
privilegeId,
|
|
||||||
projectId: workspaceId
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await deleteProjectUserAdditionalPrivilege.mutateAsync({
|
|
||||||
privilegeId,
|
|
||||||
workspaceId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
handlePopUpClose("deletePrivilege");
|
|
||||||
createNotification({
|
|
||||||
type: "success",
|
|
||||||
text: "Successfully removed privilege"
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
createNotification({
|
|
||||||
type: "error",
|
|
||||||
text: "Failed to delete privilege"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (popUp.modifyPrivilege.isOpen) {
|
|
||||||
const privilegeDetails = popUp?.modifyPrivilege?.data as {
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
key="panel-additional-permission"
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
initial={{ opacity: 0, translateX: 30 }}
|
|
||||||
animate={{ opacity: 1, translateX: 0 }}
|
|
||||||
exit={{ opacity: 0, translateX: 30 }}
|
|
||||||
>
|
|
||||||
<AdditionalPrivilegeForm
|
|
||||||
onGoBack={() => handlePopUpClose("modifyPrivilege")}
|
|
||||||
privilegeId={privilegeDetails?.id}
|
|
||||||
workspaceId={workspaceId}
|
|
||||||
isIdentity={isIdentity}
|
|
||||||
actorId={actorId}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
key="panel-privileges-list"
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
initial={{ opacity: 0, translateX: 30 }}
|
|
||||||
animate={{ opacity: 1, translateX: 0 }}
|
|
||||||
exit={{ opacity: 0, translateX: 30 }}
|
|
||||||
>
|
|
||||||
<div className="mb-8 flex items-center justify-between rounded-lg">
|
|
||||||
<h1 className="text-xl font-semibold capitalize text-mineshaft-100">
|
|
||||||
Additional Privileges - {name}
|
|
||||||
</h1>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<Button
|
|
||||||
onClick={onGoBack}
|
|
||||||
variant="outline_bg"
|
|
||||||
leftIcon={<FontAwesomeIcon icon={faArrowLeft} />}
|
|
||||||
>
|
|
||||||
Go back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
|
||||||
onClick={() => handlePopUpOpen("modifyPrivilege")}
|
|
||||||
>
|
|
||||||
New Privilege
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-6 flex flex-col space-y-4">
|
|
||||||
{privileges.length === 0 && (
|
|
||||||
<EmptyState
|
|
||||||
title={`${isIdentity ? "Machine identity" : "User"} has no additional privileges`}
|
|
||||||
iconSize="3x"
|
|
||||||
icon={faUserShield}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{privileges.map(({ id, name: privilegeName, description, slug, ...dto }) => (
|
|
||||||
<div
|
|
||||||
className="flex items-center space-x-4 rounded-md bg-mineshaft-800 p-4 px-6"
|
|
||||||
key={id}
|
|
||||||
>
|
|
||||||
<div className="flex flex-grow flex-col">
|
|
||||||
<div className="mb-1 flex items-center text-lg font-medium">
|
|
||||||
<span className="capitalize">{privilegeName}</span>
|
|
||||||
<Tag size="xs" className="ml-2">
|
|
||||||
{slug}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs font-light capitalize">{description}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<AdditionalPrivilegeTemporaryAccess
|
|
||||||
isIdentity={isIdentity}
|
|
||||||
privilegeId={id}
|
|
||||||
workspaceId={workspaceId}
|
|
||||||
temporaryConfig={!dto.isTemporary ? { isTemporary: false } : { ...dto }}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
size="sm"
|
|
||||||
variant="outline_bg"
|
|
||||||
ariaLabel="update"
|
|
||||||
onClick={() => handlePopUpOpen("modifyPrivilege", { id })}
|
|
||||||
>
|
|
||||||
<Tooltip content="Edit">
|
|
||||||
<FontAwesomeIcon icon={faPencil} />
|
|
||||||
</Tooltip>
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
size="sm"
|
|
||||||
colorSchema="danger"
|
|
||||||
variant="outline_bg"
|
|
||||||
ariaLabel="delete-privilege"
|
|
||||||
onClick={() => handlePopUpOpen("deletePrivilege", { name: privilegeName, id })}
|
|
||||||
>
|
|
||||||
<Tooltip content="Delete">
|
|
||||||
<FontAwesomeIcon icon={faTrash} />
|
|
||||||
</Tooltip>
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<DeleteActionModal
|
|
||||||
isOpen={popUp.deletePrivilege.isOpen}
|
|
||||||
title={`Are you sure want to remove privilege ${(popUp?.deletePrivilege.data as { name: string })?.name || " "
|
|
||||||
} for user ${name}?`}
|
|
||||||
onChange={(isOpen) => handlePopUpToggle("deletePrivilege", isOpen)}
|
|
||||||
deleteKey="delete"
|
|
||||||
onDeleteApproved={async () =>
|
|
||||||
onPrivilegeDelete((popUp?.deletePrivilege.data as { id: string }).id)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-219
@@ -1,219 +0,0 @@
|
|||||||
import { Controller, useForm } from "react-hook-form";
|
|
||||||
import { faClock } 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 { createNotification } from "@app/components/notifications";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
FormControl,
|
|
||||||
IconButton,
|
|
||||||
Input,
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
Tag,
|
|
||||||
Tooltip
|
|
||||||
} from "@app/components/v2";
|
|
||||||
import { usePopUp } from "@app/hooks";
|
|
||||||
import {
|
|
||||||
useUpdateIdentityProjectAdditionalPrivilege,
|
|
||||||
useUpdateProjectUserAdditionalPrivilege
|
|
||||||
} from "@app/hooks/api";
|
|
||||||
import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/hooks/api/identityProjectAdditionalPrivilege/types";
|
|
||||||
import { ProjectUserAdditionalPrivilegeTemporaryMode } from "@app/hooks/api/projectUserAdditionalPrivilege/types";
|
|
||||||
|
|
||||||
const temporaryRoleFormSchema = z.object({
|
|
||||||
temporaryRange: z.string().min(1, "Required")
|
|
||||||
});
|
|
||||||
|
|
||||||
type TTemporaryRoleFormSchema = z.infer<typeof temporaryRoleFormSchema>;
|
|
||||||
|
|
||||||
type TTemporaryRoleFormProps = {
|
|
||||||
privilegeId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
isIdentity?: boolean;
|
|
||||||
temporaryConfig?: {
|
|
||||||
isTemporary?: boolean;
|
|
||||||
temporaryAccessEndTime?: string | null;
|
|
||||||
temporaryAccessStartTime?: string | null;
|
|
||||||
temporaryRange?: string | null;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AdditionalPrivilegeTemporaryAccess = ({
|
|
||||||
temporaryConfig: defaultValues = {},
|
|
||||||
workspaceId,
|
|
||||||
privilegeId,
|
|
||||||
isIdentity
|
|
||||||
}: 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 || "");
|
|
||||||
|
|
||||||
const updateProjectUserAdditionalPrivilege = useUpdateProjectUserAdditionalPrivilege();
|
|
||||||
const updateProjectIdentityAdditionalPrivilege = useUpdateIdentityProjectAdditionalPrivilege();
|
|
||||||
|
|
||||||
const handleGrantTemporaryAccess = async (el: TTemporaryRoleFormSchema) => {
|
|
||||||
try {
|
|
||||||
if (isIdentity) {
|
|
||||||
await updateProjectIdentityAdditionalPrivilege.mutateAsync({
|
|
||||||
privilegeId: privilegeId as string,
|
|
||||||
projectId: workspaceId,
|
|
||||||
isTemporary: true,
|
|
||||||
temporaryRange: el.temporaryRange,
|
|
||||||
temporaryAccessStartTime: new Date().toISOString(),
|
|
||||||
temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await updateProjectUserAdditionalPrivilege.mutateAsync({
|
|
||||||
privilegeId: privilegeId as string,
|
|
||||||
workspaceId,
|
|
||||||
isTemporary: true,
|
|
||||||
temporaryRange: el.temporaryRange,
|
|
||||||
temporaryAccessStartTime: new Date().toISOString(),
|
|
||||||
temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative
|
|
||||||
});
|
|
||||||
}
|
|
||||||
createNotification({ type: "success", text: "Successfully updated access" });
|
|
||||||
handlePopUpToggle("setTempRole");
|
|
||||||
} catch (err) {
|
|
||||||
console.log(err);
|
|
||||||
createNotification({ type: "error", text: "Failed to update access" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRevokeTemporaryAccess = async () => {
|
|
||||||
try {
|
|
||||||
if (isIdentity) {
|
|
||||||
await updateProjectIdentityAdditionalPrivilege.mutateAsync({
|
|
||||||
privilegeId: privilegeId as string,
|
|
||||||
projectId: workspaceId,
|
|
||||||
isTemporary: false
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await updateProjectUserAdditionalPrivilege.mutateAsync({
|
|
||||||
privilegeId: privilegeId as string,
|
|
||||||
workspaceId,
|
|
||||||
isTemporary: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
createNotification({ type: "success", text: "Successfully updated access" });
|
|
||||||
handlePopUpToggle("setTempRole");
|
|
||||||
} catch (err) {
|
|
||||||
console.log(err);
|
|
||||||
createNotification({ type: "error", text: "Failed to update access" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover
|
|
||||||
open={popUp.setTempRole.isOpen}
|
|
||||||
onOpenChange={(isOpen) => {
|
|
||||||
handlePopUpToggle("setTempRole", isOpen);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PopoverTrigger>
|
|
||||||
<IconButton ariaLabel="role-temp" size="sm" variant="outline_bg">
|
|
||||||
<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">
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
isLoading={
|
|
||||||
updateProjectUserAdditionalPrivilege.isLoading &&
|
|
||||||
updateProjectUserAdditionalPrivilege.variables?.isTemporary
|
|
||||||
}
|
|
||||||
isDisabled={
|
|
||||||
updateProjectUserAdditionalPrivilege.isLoading &&
|
|
||||||
updateProjectUserAdditionalPrivilege.variables?.isTemporary
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
handleSubmit(({ temporaryRange }) => {
|
|
||||||
handleGrantTemporaryAccess({ temporaryRange });
|
|
||||||
})();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isTemporaryFieldValue ? "Restart" : "Grant access"}
|
|
||||||
</Button>
|
|
||||||
{isTemporaryFieldValue && (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="outline_bg"
|
|
||||||
colorSchema="danger"
|
|
||||||
onClick={handleRevokeTemporaryAccess}
|
|
||||||
isLoading={
|
|
||||||
updateProjectUserAdditionalPrivilege.isLoading &&
|
|
||||||
!updateProjectUserAdditionalPrivilege.variables?.isTemporary
|
|
||||||
}
|
|
||||||
isDisabled={
|
|
||||||
updateProjectUserAdditionalPrivilege.isLoading &&
|
|
||||||
!updateProjectUserAdditionalPrivilege.variables?.isTemporary
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Revoke Access
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { AdditionalPrivilegeSection } from "./AdditionalPrivilegeSection";
|
|
||||||
@@ -3,13 +3,11 @@ import {
|
|||||||
faArrowUpRightFromSquare,
|
faArrowUpRightFromSquare,
|
||||||
faPlus,
|
faPlus,
|
||||||
faServer,
|
faServer,
|
||||||
faUserShield,
|
|
||||||
faXmark
|
faXmark
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
@@ -32,13 +30,10 @@ import { withProjectPermission } from "@app/hoc";
|
|||||||
import { useDeleteIdentityFromWorkspace, useGetWorkspaceIdentityMemberships } from "@app/hooks/api";
|
import { useDeleteIdentityFromWorkspace, useGetWorkspaceIdentityMemberships } from "@app/hooks/api";
|
||||||
import { usePopUp } from "@app/hooks/usePopUp";
|
import { usePopUp } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
import { AdditionalPrivilegeSection } from "../AdditionalPrivilegeSection";
|
|
||||||
import { IdentityModal } from "./components/IdentityModal";
|
import { IdentityModal } from "./components/IdentityModal";
|
||||||
import { IdentityRoles } from "./components/IdentityRoles";
|
|
||||||
|
|
||||||
export const IdentityTab = withProjectPermission(
|
export const IdentityTab = withProjectPermission(
|
||||||
() => {
|
() => {
|
||||||
|
|
||||||
const { currentWorkspace } = useWorkspace();
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
|
||||||
const workspaceId = currentWorkspace?.id ?? "";
|
const workspaceId = currentWorkspace?.id ?? "";
|
||||||
@@ -78,33 +73,6 @@ export const IdentityTab = withProjectPermission(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (popUp.additionalPrivilege.isOpen) {
|
|
||||||
const privilegeDetails = popUp?.additionalPrivilege?.data as {
|
|
||||||
name: string;
|
|
||||||
index: number;
|
|
||||||
identityId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
key="panel-additional-permission"
|
|
||||||
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
initial={{ opacity: 0, translateX: 30 }}
|
|
||||||
animate={{ opacity: 1, translateX: 0 }}
|
|
||||||
exit={{ opacity: 0, translateX: 30 }}
|
|
||||||
>
|
|
||||||
<AdditionalPrivilegeSection
|
|
||||||
isIdentity
|
|
||||||
onGoBack={() => handlePopUpClose("additionalPrivilege")}
|
|
||||||
privileges={data?.[privilegeDetails.index]?.additionalPrivileges || []}
|
|
||||||
name={privilegeDetails.name}
|
|
||||||
actorId={privilegeDetails.identityId}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="panel-identity"
|
key="panel-identity"
|
||||||
@@ -159,82 +127,39 @@ export const IdentityTab = withProjectPermission(
|
|||||||
{!isLoading &&
|
{!isLoading &&
|
||||||
data &&
|
data &&
|
||||||
data.length > 0 &&
|
data.length > 0 &&
|
||||||
data.map(
|
data.map(({ identity: { id, name }, createdAt }) => {
|
||||||
({ identity: { id, name }, roles, createdAt, additionalPrivileges }, index) => {
|
return (
|
||||||
const hasAdditionalPrivilege = Boolean(additionalPrivileges.length);
|
<Tr className="h-10" key={`st-v3-${id}`}>
|
||||||
return (
|
<Td>{name}</Td>
|
||||||
<Tr className="h-10" key={`st-v3-${id}`}>
|
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
|
||||||
<Td>{name}</Td>
|
<Td className="flex justify-end">
|
||||||
<Td>
|
<ProjectPermissionCan
|
||||||
<ProjectPermissionCan
|
I={ProjectPermissionActions.Delete}
|
||||||
I={ProjectPermissionActions.Edit}
|
a={ProjectPermissionSub.Identity}
|
||||||
a={ProjectPermissionSub.Identity}
|
>
|
||||||
>
|
{(isAllowed) => (
|
||||||
{(isAllowed) => (
|
<IconButton
|
||||||
<IdentityRoles
|
onClick={() => {
|
||||||
roles={roles}
|
handlePopUpOpen("deleteIdentity", {
|
||||||
disableEdit={!isAllowed}
|
identityId: id,
|
||||||
identityId={id}
|
name
|
||||||
/>
|
});
|
||||||
)}
|
}}
|
||||||
</ProjectPermissionCan>
|
size="lg"
|
||||||
</Td>
|
colorSchema="danger"
|
||||||
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
|
variant="plain"
|
||||||
<Td className="flex justify-end">
|
ariaLabel="update"
|
||||||
<div className="flex items-center space-x-2">
|
className="ml-4"
|
||||||
<ProjectPermissionCan
|
isDisabled={!isAllowed}
|
||||||
I={ProjectPermissionActions.Edit}
|
|
||||||
a={ProjectPermissionSub.Member}
|
|
||||||
allowedLabel="Additional Privilege"
|
|
||||||
>
|
>
|
||||||
{(isAllowed) => (
|
<FontAwesomeIcon icon={faXmark} />
|
||||||
<IconButton
|
</IconButton>
|
||||||
size="lg"
|
)}
|
||||||
variant="plain"
|
</ProjectPermissionCan>
|
||||||
ariaLabel="update"
|
</Td>
|
||||||
className={twMerge(hasAdditionalPrivilege && "text-primary")}
|
</Tr>
|
||||||
isDisabled={!isAllowed}
|
);
|
||||||
onClick={() =>
|
})}
|
||||||
handlePopUpOpen("additionalPrivilege", {
|
|
||||||
name,
|
|
||||||
index,
|
|
||||||
identityId: id
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faUserShield} />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</ProjectPermissionCan>
|
|
||||||
<ProjectPermissionCan
|
|
||||||
I={ProjectPermissionActions.Delete}
|
|
||||||
a={ProjectPermissionSub.Identity}
|
|
||||||
>
|
|
||||||
{(isAllowed) => (
|
|
||||||
<IconButton
|
|
||||||
onClick={() => {
|
|
||||||
handlePopUpOpen("deleteIdentity", {
|
|
||||||
identityId: id,
|
|
||||||
name
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
size="lg"
|
|
||||||
colorSchema="danger"
|
|
||||||
variant="plain"
|
|
||||||
ariaLabel="update"
|
|
||||||
className="ml-4"
|
|
||||||
isDisabled={!isAllowed}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faXmark} />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</ProjectPermissionCan>
|
|
||||||
</div>
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
{!isLoading && data && data?.length === 0 && (
|
{!isLoading && data && data?.length === 0 && (
|
||||||
<Tr>
|
<Tr>
|
||||||
<Td colSpan={7}>
|
<Td colSpan={7}>
|
||||||
|
|||||||
+178
-111
@@ -3,10 +3,11 @@ import { Controller, useForm } from "react-hook-form";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
|
faClock,
|
||||||
|
faEdit,
|
||||||
faMagnifyingGlass,
|
faMagnifyingGlass,
|
||||||
faPlus,
|
faPlus,
|
||||||
faUsers,
|
faUsers,
|
||||||
faUserShield,
|
|
||||||
faXmark
|
faXmark
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
@@ -22,6 +23,9 @@ import {
|
|||||||
DeleteActionModal,
|
DeleteActionModal,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
HoverCard,
|
||||||
|
HoverCardContent,
|
||||||
|
HoverCardTrigger,
|
||||||
IconButton,
|
IconButton,
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -31,10 +35,12 @@ import {
|
|||||||
Table,
|
Table,
|
||||||
TableContainer,
|
TableContainer,
|
||||||
TableSkeleton,
|
TableSkeleton,
|
||||||
|
Tag,
|
||||||
TBody,
|
TBody,
|
||||||
Td,
|
Td,
|
||||||
Th,
|
Th,
|
||||||
THead,
|
THead,
|
||||||
|
Tooltip,
|
||||||
Tr,
|
Tr,
|
||||||
UpgradePlanModal
|
UpgradePlanModal
|
||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
@@ -54,10 +60,11 @@ import {
|
|||||||
useGetUserWsKey,
|
useGetUserWsKey,
|
||||||
useGetWorkspaceUsers
|
useGetWorkspaceUsers
|
||||||
} from "@app/hooks/api";
|
} from "@app/hooks/api";
|
||||||
|
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||||
|
import { TWorkspaceUser } from "@app/hooks/api/types";
|
||||||
import { ProjectVersion } from "@app/hooks/api/workspace/types";
|
import { ProjectVersion } from "@app/hooks/api/workspace/types";
|
||||||
|
|
||||||
import { AdditionalPrivilegeSection } from "../AdditionalPrivilegeSection";
|
import { MemberRoleForm } from "./MemberRoleForm";
|
||||||
import { MemberRoles } from "./MemberRoles";
|
|
||||||
|
|
||||||
const addMemberFormSchema = z.object({
|
const addMemberFormSchema = z.object({
|
||||||
orgMembershipId: z.string().trim()
|
orgMembershipId: z.string().trim()
|
||||||
@@ -65,8 +72,14 @@ const addMemberFormSchema = z.object({
|
|||||||
|
|
||||||
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
|
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
|
||||||
|
|
||||||
export const MemberListTab = () => {
|
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
|
||||||
|
const formatRoleName = (role: string, customRoleName?: string) => {
|
||||||
|
if (role === ProjectMembershipRole.Custom) return customRoleName;
|
||||||
|
if (role === ProjectMembershipRole.Member) return "Developer";
|
||||||
|
return role;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MemberListTab = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { currentOrg } = useOrganization();
|
const { currentOrg } = useOrganization();
|
||||||
@@ -87,7 +100,7 @@ export const MemberListTab = () => {
|
|||||||
"addMember",
|
"addMember",
|
||||||
"removeMember",
|
"removeMember",
|
||||||
"upgradePlan",
|
"upgradePlan",
|
||||||
"additionalPrivilege"
|
"updateRole"
|
||||||
] as const);
|
] as const);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -195,32 +208,6 @@ export const MemberListTab = () => {
|
|||||||
);
|
);
|
||||||
}, [orgUsers, members]);
|
}, [orgUsers, members]);
|
||||||
|
|
||||||
if (popUp.additionalPrivilege.isOpen) {
|
|
||||||
const privilegeDetails = popUp?.additionalPrivilege?.data as {
|
|
||||||
name: string;
|
|
||||||
index: number;
|
|
||||||
projectMembershipId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
key="panel-additional-permission"
|
|
||||||
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
initial={{ opacity: 0, translateX: 30 }}
|
|
||||||
animate={{ opacity: 1, translateX: 0 }}
|
|
||||||
exit={{ opacity: 0, translateX: 30 }}
|
|
||||||
>
|
|
||||||
<AdditionalPrivilegeSection
|
|
||||||
onGoBack={() => handlePopUpClose("additionalPrivilege")}
|
|
||||||
privileges={members?.[privilegeDetails.index]?.additionalPrivileges || []}
|
|
||||||
name={privilegeDetails.name}
|
|
||||||
actorId={privilegeDetails.projectMembershipId}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="panel-1"
|
key="panel-1"
|
||||||
@@ -266,90 +253,149 @@ export const MemberListTab = () => {
|
|||||||
<TBody>
|
<TBody>
|
||||||
{isMembersLoading && <TableSkeleton columns={4} innerKey="project-members" />}
|
{isMembersLoading && <TableSkeleton columns={4} innerKey="project-members" />}
|
||||||
{!isMembersLoading &&
|
{!isMembersLoading &&
|
||||||
filterdUsers?.map(
|
filterdUsers?.map((projectMember, index) => {
|
||||||
(
|
const { user: u, inviteEmail, id: membershipId, roles } = projectMember;
|
||||||
{ user: u, inviteEmail, id: membershipId, roles, additionalPrivileges },
|
const name = u ? `${u.firstName} ${u.lastName}` : "-";
|
||||||
index
|
const email = u?.email || inviteEmail;
|
||||||
) => {
|
|
||||||
const name = u ? `${u.firstName} ${u.lastName}` : "-";
|
|
||||||
const email = u?.email || inviteEmail;
|
|
||||||
const hasAdditionalPrivilege = Boolean(additionalPrivileges.length);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tr key={`membership-${membershipId}`} className="w-full">
|
<Tr key={`membership-${membershipId}`} className="w-full">
|
||||||
<Td>{name}</Td>
|
<Td>{name}</Td>
|
||||||
<Td>{email}</Td>
|
<Td>{email}</Td>
|
||||||
<Td>
|
<Td>
|
||||||
<ProjectPermissionCan
|
<div className="flex items-center space-x-2">
|
||||||
I={ProjectPermissionActions.Edit}
|
{roles
|
||||||
a={ProjectPermissionSub.Member}
|
.slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
|
||||||
>
|
.map(
|
||||||
{(isAllowed) => (
|
({
|
||||||
<MemberRoles
|
role,
|
||||||
roles={roles}
|
customRoleName,
|
||||||
disableEdit={u.id === user?.id || !isAllowed}
|
id,
|
||||||
onOpenUpgradeModal={(description) =>
|
isTemporary,
|
||||||
handlePopUpOpen("upgradePlan", { description })
|
temporaryAccessEndTime
|
||||||
}
|
}) => {
|
||||||
membershipId={membershipId}
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
)}
|
)}
|
||||||
</ProjectPermissionCan>
|
{roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && (
|
||||||
</Td>
|
<HoverCard>
|
||||||
<Td>
|
<HoverCardTrigger>
|
||||||
{userId !== u?.id && (
|
<Tag>+{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE}</Tag>
|
||||||
<div className="flex items-center space-x-2">
|
</HoverCardTrigger>
|
||||||
<ProjectPermissionCan
|
<HoverCardContent className="border border-gray-700 bg-mineshaft-800 p-4">
|
||||||
I={ProjectPermissionActions.Edit}
|
{roles
|
||||||
a={ProjectPermissionSub.Member}
|
.slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
|
||||||
allowedLabel="Additional Privilege"
|
.map(
|
||||||
>
|
({
|
||||||
{(isAllowed) => (
|
role,
|
||||||
<IconButton
|
customRoleName,
|
||||||
size="lg"
|
id,
|
||||||
variant="plain"
|
isTemporary,
|
||||||
ariaLabel="update"
|
temporaryAccessEndTime
|
||||||
className={twMerge(hasAdditionalPrivilege && "text-primary")}
|
}) => {
|
||||||
isDisabled={userId === u?.id || !isAllowed}
|
const isExpired =
|
||||||
onClick={() =>
|
new Date() >
|
||||||
handlePopUpOpen("additionalPrivilege", {
|
new Date(temporaryAccessEndTime || ("" as string));
|
||||||
name: `${user.firstName} ${user.lastName || ""}`,
|
return (
|
||||||
index,
|
<Tag key={id} className="capitalize">
|
||||||
projectMembershipId: membershipId
|
<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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
>
|
)}
|
||||||
<FontAwesomeIcon icon={faUserShield} />
|
</HoverCardContent>
|
||||||
</IconButton>
|
</HoverCard>
|
||||||
)}
|
|
||||||
</ProjectPermissionCan>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</Td>
|
{userId !== u?.id && (
|
||||||
</Tr>
|
<Tooltip content="Edit permission">
|
||||||
);
|
<IconButton
|
||||||
}
|
size="sm"
|
||||||
)}
|
variant="plain"
|
||||||
|
ariaLabel="update-role"
|
||||||
|
onClick={() =>
|
||||||
|
handlePopUpOpen("updateRole", { ...projectMember, index })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faEdit} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
{userId !== u?.id && (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</TBody>
|
</TBody>
|
||||||
</Table>
|
</Table>
|
||||||
{!isMembersLoading && filterdUsers?.length === 0 && (
|
{!isMembersLoading && filterdUsers?.length === 0 && (
|
||||||
@@ -418,6 +464,27 @@ export const MemberListTab = () => {
|
|||||||
)}
|
)}
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
isOpen={popUp.updateRole.isOpen}
|
||||||
|
onOpenChange={(state) => handlePopUpToggle("updateRole", state)}
|
||||||
|
>
|
||||||
|
<ModalContent
|
||||||
|
className="max-w-4xl"
|
||||||
|
title={`Manage Access for ${(popUp.updateRole.data as TWorkspaceUser)?.user?.email}`}
|
||||||
|
subTitle={`
|
||||||
|
Configure role-based access control by Infisical users a mix of one built-in role, multiple custom roles, and multiple specific privileges. A user will gain access to alll actions within the roles assigned to them, not just the actions those roles share in common. You must choose at least one permanent role.
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<MemberRoleForm
|
||||||
|
onOpenUpgradeModal={(description) => handlePopUpOpen("upgradePlan", { description })}
|
||||||
|
projectMember={
|
||||||
|
filterdUsers?.[
|
||||||
|
(popUp.updateRole?.data as TWorkspaceUser & { index: number })?.index
|
||||||
|
] as TWorkspaceUser
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
<DeleteActionModal
|
<DeleteActionModal
|
||||||
isOpen={popUp.removeMember.isOpen}
|
isOpen={popUp.removeMember.isOpen}
|
||||||
deleteKey="remove"
|
deleteKey="remove"
|
||||||
|
|||||||
+338
@@ -0,0 +1,338 @@
|
|||||||
|
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||||
|
import { faCaretDown, faClock, faClose } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { formatDistance } from "date-fns";
|
||||||
|
import ms from "ms";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { TtlFormLabel } from "@app/components/features";
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
Select,
|
||||||
|
SelectItem,
|
||||||
|
Spinner,
|
||||||
|
Tag,
|
||||||
|
Tooltip
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import {
|
||||||
|
ProjectPermissionActions,
|
||||||
|
ProjectPermissionSub,
|
||||||
|
useProjectPermission,
|
||||||
|
useSubscription,
|
||||||
|
useWorkspace
|
||||||
|
} from "@app/context";
|
||||||
|
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";
|
||||||
|
|
||||||
|
const roleFormSchema = z.object({
|
||||||
|
roles: z
|
||||||
|
.object({
|
||||||
|
slug: z.string(),
|
||||||
|
temporaryAccess: z.discriminatedUnion("isTemporary", [
|
||||||
|
z.object({
|
||||||
|
isTemporary: z.literal(true),
|
||||||
|
temporaryRange: z.string().min(1),
|
||||||
|
temporaryAccessStartTime: z.string().datetime(),
|
||||||
|
temporaryAccessEndTime: z.string().datetime().nullable().optional()
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
isTemporary: z.literal(false)
|
||||||
|
})
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.array()
|
||||||
|
});
|
||||||
|
type TRoleForm = z.infer<typeof roleFormSchema>;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
projectMember: TWorkspaceUser;
|
||||||
|
onOpenUpgradeModal: (title: string) => void;
|
||||||
|
};
|
||||||
|
export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props) => {
|
||||||
|
const { subscription } = useSubscription();
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
const workspaceId = currentWorkspace?.id || "";
|
||||||
|
const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
|
||||||
|
const { permission } = useProjectPermission();
|
||||||
|
const isMemberEditDisabled = permission.cannot(
|
||||||
|
ProjectPermissionActions.Edit,
|
||||||
|
ProjectPermissionSub.Member
|
||||||
|
);
|
||||||
|
|
||||||
|
const roleForm = useForm<TRoleForm>({
|
||||||
|
resolver: zodResolver(roleFormSchema),
|
||||||
|
values: {
|
||||||
|
roles: projectMember?.roles?.map(({ customRoleSlug, role, ...dto }) => ({
|
||||||
|
slug: customRoleSlug || role,
|
||||||
|
temporaryAccess: dto.isTemporary
|
||||||
|
? {
|
||||||
|
isTemporary: true,
|
||||||
|
temporaryRange: dto.temporaryRange,
|
||||||
|
temporaryAccessEndTime: dto.temporaryAccessEndTime,
|
||||||
|
temporaryAccessStartTime: dto.temporaryAccessStartTime
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
isTemporary: dto.isTemporary
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const selectedRoleList = useFieldArray({
|
||||||
|
name: "roles",
|
||||||
|
control: roleForm.control
|
||||||
|
});
|
||||||
|
|
||||||
|
const formRoleField = roleForm.watch("roles");
|
||||||
|
|
||||||
|
const updateMembershipRole = useUpdateUserWorkspaceRole();
|
||||||
|
|
||||||
|
const handleRoleUpdate = async (data: TRoleForm) => {
|
||||||
|
if (updateMembershipRole.isLoading) return;
|
||||||
|
|
||||||
|
const sanitizedRoles = data.roles.map((el) => {
|
||||||
|
const { isTemporary } = el.temporaryAccess;
|
||||||
|
if (!isTemporary) {
|
||||||
|
return { role: el.slug, isTemporary: false as const };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
role: el.slug,
|
||||||
|
isTemporary: true as const,
|
||||||
|
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
|
||||||
|
temporaryRange: el.temporaryAccess.temporaryRange,
|
||||||
|
temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasCustomRoleSelected = sanitizedRoles.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: projectMember.id,
|
||||||
|
roles: sanitizedRoles
|
||||||
|
});
|
||||||
|
createNotification({ text: "Successfully updated roles", type: "success" });
|
||||||
|
roleForm.reset(undefined, { keepValues: true });
|
||||||
|
} catch (err) {
|
||||||
|
createNotification({ text: "Failed to update role", type: "error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isRolesLoading)
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-center justify-center p-8">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-medium">Roles</div>
|
||||||
|
<p className="text-sm text-mineshaft-400">Select one of the pre-defined or custom roles.</p>
|
||||||
|
<div>
|
||||||
|
<form onSubmit={roleForm.handleSubmit(handleRoleUpdate)}>
|
||||||
|
<div className="mt-2 flex flex-col space-y-2">
|
||||||
|
{selectedRoleList.fields.map(({ id }, index) => {
|
||||||
|
const { temporaryAccess } = formRoleField[index];
|
||||||
|
const isTemporary = temporaryAccess?.isTemporary;
|
||||||
|
const isExpired =
|
||||||
|
temporaryAccess.isTemporary &&
|
||||||
|
new Date() > new Date(temporaryAccess.temporaryAccessEndTime || "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={id} className="flex items-center space-x-2">
|
||||||
|
<Controller
|
||||||
|
control={roleForm.control}
|
||||||
|
name={`roles.${index}.slug`}
|
||||||
|
render={({ field: { onChange, ...field } }) => (
|
||||||
|
<Select
|
||||||
|
defaultValue={field.value}
|
||||||
|
{...field}
|
||||||
|
isDisabled={isMemberEditDisabled}
|
||||||
|
onValueChange={(e) => onChange(e)}
|
||||||
|
className="w-full bg-mineshaft-600"
|
||||||
|
>
|
||||||
|
{projectRoles?.map(({ name, slug, id: projectRoleId }) => (
|
||||||
|
<SelectItem value={slug} key={projectRoleId}>
|
||||||
|
{name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger disabled={isMemberEditDisabled}>
|
||||||
|
<Tooltip
|
||||||
|
asChild
|
||||||
|
content={isExpired ? "Timed access expired" : "Grant timed access"}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline_bg"
|
||||||
|
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
|
||||||
|
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
|
||||||
|
isDisabled={isMemberEditDisabled}
|
||||||
|
className={twMerge(
|
||||||
|
"border-none bg-mineshaft-600 py-2 capitalize",
|
||||||
|
isTemporary && "text-primary",
|
||||||
|
isExpired && "text-red-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!temporaryAccess?.isTemporary
|
||||||
|
? "Permanent"
|
||||||
|
: formatDistance(
|
||||||
|
new Date(temporaryAccess.temporaryAccessEndTime || ""),
|
||||||
|
new Date()
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</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={roleForm.control}
|
||||||
|
defaultValue="1h"
|
||||||
|
name={`roles.${index}.temporaryAccess.temporaryRange`}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label={<TtlFormLabel label="Validity" />}
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
onClick={() => {
|
||||||
|
const temporaryRange = roleForm.getValues(
|
||||||
|
`roles.${index}.temporaryAccess.temporaryRange`
|
||||||
|
);
|
||||||
|
if (!temporaryRange) {
|
||||||
|
roleForm.setError(
|
||||||
|
`roles.${index}.temporaryAccess.temporaryRange`,
|
||||||
|
{ type: "required", message: "Required" },
|
||||||
|
{ shouldFocus: true }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
roleForm.clearErrors(`roles.${index}.temporaryAccess.temporaryRange`);
|
||||||
|
roleForm.setValue(
|
||||||
|
`roles.${index}.temporaryAccess`,
|
||||||
|
{
|
||||||
|
isTemporary: true,
|
||||||
|
temporaryAccessStartTime: new Date().toISOString(),
|
||||||
|
temporaryRange,
|
||||||
|
temporaryAccessEndTime: new Date(
|
||||||
|
new Date().getTime() + ms(temporaryRange)
|
||||||
|
).toISOString()
|
||||||
|
},
|
||||||
|
{ shouldDirty: true }
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{temporaryAccess.isTemporary ? "Restart" : "Grant"}
|
||||||
|
</Button>
|
||||||
|
{temporaryAccess.isTemporary && (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="outline_bg"
|
||||||
|
colorSchema="danger"
|
||||||
|
onClick={() => {
|
||||||
|
roleForm.setValue(`roles.${index}.temporaryAccess`, {
|
||||||
|
isTemporary: false
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Revoke Access
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<Tooltip content={isMemberEditDisabled ? "Access restricted" : "Remove"}>
|
||||||
|
<IconButton
|
||||||
|
variant="outline_bg"
|
||||||
|
className="border-none bg-mineshaft-600 py-3"
|
||||||
|
ariaLabel="delete-role"
|
||||||
|
isDisabled={isMemberEditDisabled}
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedRoleList.fields.length > 1) {
|
||||||
|
selectedRoleList.remove(index);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faClose} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex justify-between space-x-2">
|
||||||
|
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Member}>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<Button
|
||||||
|
variant="outline_bg"
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
onClick={() =>
|
||||||
|
selectedRoleList.append({
|
||||||
|
slug: ProjectMembershipRole.Member,
|
||||||
|
temporaryAccess: { isTemporary: false }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Add Role
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className={twMerge(
|
||||||
|
"transition-all",
|
||||||
|
"opacity-0",
|
||||||
|
roleForm.formState.isDirty && "opacity-100"
|
||||||
|
)}
|
||||||
|
isLoading={roleForm.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
import { TWorkspaceUser } from "@app/hooks/api/types";
|
||||||
|
|
||||||
|
import { MemberRbacSection } from "./MemberRbacSection";
|
||||||
|
import { SpecificPrivilegeSection } from "./SpecificPrivilegeSection";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
projectMember: TWorkspaceUser;
|
||||||
|
onOpenUpgradeModal: (title: string) => void;
|
||||||
|
};
|
||||||
|
export const MemberRoleForm = ({ projectMember, onOpenUpgradeModal }: Props) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<MemberRbacSection
|
||||||
|
projectMember={projectMember}
|
||||||
|
onOpenUpgradeModal={onOpenUpgradeModal}
|
||||||
|
/>
|
||||||
|
<SpecificPrivilegeSection membershipId={projectMember?.id} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+497
@@ -0,0 +1,497 @@
|
|||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { faCancel, faCaretDown, faClock, faClose, faSave } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { formatDistance } from "date-fns";
|
||||||
|
import ms from "ms";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { TtlFormLabel } from "@app/components/features";
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
DeleteActionModal,
|
||||||
|
FormControl,
|
||||||
|
FormLabel,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
Select,
|
||||||
|
SelectItem,
|
||||||
|
Spinner,
|
||||||
|
Tag,
|
||||||
|
Tooltip
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import {
|
||||||
|
ProjectPermissionActions,
|
||||||
|
ProjectPermissionSub,
|
||||||
|
useProjectPermission,
|
||||||
|
useWorkspace
|
||||||
|
} from "@app/context";
|
||||||
|
import { usePopUp } from "@app/hooks";
|
||||||
|
import {
|
||||||
|
TProjectUserPrivilege,
|
||||||
|
useCreateProjectUserAdditionalPrivilege,
|
||||||
|
useDeleteProjectUserAdditionalPrivilege,
|
||||||
|
useListProjectUserPrivileges,
|
||||||
|
useUpdateProjectUserAdditionalPrivilege
|
||||||
|
} from "@app/hooks/api";
|
||||||
|
|
||||||
|
const secretPermissionSchema = z.object({
|
||||||
|
secretPath: z.string().optional(),
|
||||||
|
environmentSlug: z.string(),
|
||||||
|
[ProjectPermissionActions.Edit]: z.boolean().optional(),
|
||||||
|
[ProjectPermissionActions.Read]: z.boolean().optional(),
|
||||||
|
[ProjectPermissionActions.Create]: z.boolean().optional(),
|
||||||
|
[ProjectPermissionActions.Delete]: z.boolean().optional(),
|
||||||
|
temporaryAccess: z.discriminatedUnion("isTemporary", [
|
||||||
|
z.object({
|
||||||
|
isTemporary: z.literal(true),
|
||||||
|
temporaryRange: z.string().min(1),
|
||||||
|
temporaryAccessStartTime: z.string().datetime(),
|
||||||
|
temporaryAccessEndTime: z.string().datetime().nullable().optional()
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
isTemporary: z.literal(false)
|
||||||
|
})
|
||||||
|
])
|
||||||
|
});
|
||||||
|
type TSecretPermissionForm = z.infer<typeof secretPermissionSchema>;
|
||||||
|
const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPrivilege }) => {
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
|
||||||
|
"deletePrivilege"
|
||||||
|
] as const);
|
||||||
|
const { permission } = useProjectPermission();
|
||||||
|
const isMemberEditDisabled = permission.cannot(
|
||||||
|
ProjectPermissionActions.Edit,
|
||||||
|
ProjectPermissionSub.Member
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateUserPrivilege = useUpdateProjectUserAdditionalPrivilege();
|
||||||
|
const deleteUserPrivilege = useDeleteProjectUserAdditionalPrivilege();
|
||||||
|
|
||||||
|
const privilegeForm = useForm<TSecretPermissionForm>({
|
||||||
|
resolver: zodResolver(secretPermissionSchema),
|
||||||
|
values: {
|
||||||
|
environmentSlug: privilege.permissions?.[0]?.conditions?.environment,
|
||||||
|
// secret path will be inside $glob operator
|
||||||
|
secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "",
|
||||||
|
read: privilege.permissions?.some(({ action }) =>
|
||||||
|
action.includes(ProjectPermissionActions.Read)
|
||||||
|
),
|
||||||
|
edit: privilege.permissions?.some(({ action }) =>
|
||||||
|
action.includes(ProjectPermissionActions.Edit)
|
||||||
|
),
|
||||||
|
create: privilege.permissions?.some(({ action }) =>
|
||||||
|
action.includes(ProjectPermissionActions.Create)
|
||||||
|
),
|
||||||
|
delete: privilege.permissions?.some(({ action }) =>
|
||||||
|
action.includes(ProjectPermissionActions.Delete)
|
||||||
|
),
|
||||||
|
// zod will pick it
|
||||||
|
temporaryAccess: privilege
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const temporaryAccessField = privilegeForm.watch("temporaryAccess");
|
||||||
|
const isTemporary = temporaryAccessField?.isTemporary;
|
||||||
|
const isExpired =
|
||||||
|
temporaryAccessField.isTemporary &&
|
||||||
|
new Date() > new Date(temporaryAccessField.temporaryAccessEndTime || "");
|
||||||
|
|
||||||
|
const handleUpdatePrivilege = async (data: TSecretPermissionForm) => {
|
||||||
|
if (updateUserPrivilege.isLoading) return;
|
||||||
|
try {
|
||||||
|
const actions = [
|
||||||
|
{ action: ProjectPermissionActions.Read, allowed: data.read },
|
||||||
|
{ action: ProjectPermissionActions.Create, allowed: data.create },
|
||||||
|
{ action: ProjectPermissionActions.Delete, allowed: data.delete },
|
||||||
|
{ action: ProjectPermissionActions.Edit, allowed: data.edit }
|
||||||
|
];
|
||||||
|
const conditions: Record<string, any> = { environment: data.environmentSlug };
|
||||||
|
if (data.secretPath) {
|
||||||
|
conditions.secretPath = { $glob: data.secretPath };
|
||||||
|
}
|
||||||
|
await updateUserPrivilege.mutateAsync({
|
||||||
|
privilegeId: privilege.id,
|
||||||
|
...data.temporaryAccess,
|
||||||
|
permissions: actions
|
||||||
|
.filter(({ allowed }) => allowed)
|
||||||
|
.map(({ action }) => ({
|
||||||
|
action,
|
||||||
|
subject: [ProjectPermissionSub.Secrets],
|
||||||
|
conditions
|
||||||
|
})),
|
||||||
|
projectMembershipId: privilege.projectMembershipId
|
||||||
|
});
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
text: "Successfully updated privilege"
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "Failed to update privilege"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePrivilege = async () => {
|
||||||
|
if (deleteUserPrivilege.isLoading) return;
|
||||||
|
try {
|
||||||
|
await deleteUserPrivilege.mutateAsync({
|
||||||
|
privilegeId: privilege.id,
|
||||||
|
projectMembershipId: privilege.projectMembershipId
|
||||||
|
});
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
text: "Successfully deleted privilege"
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "Failed to delete privilege"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAccessLabel = () => {
|
||||||
|
if (isExpired) return "Access expired";
|
||||||
|
if (!temporaryAccessField?.isTemporary) return "Permanent";
|
||||||
|
return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4">
|
||||||
|
<form onSubmit={privilegeForm.handleSubmit(handleUpdatePrivilege)}>
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<Controller
|
||||||
|
control={privilegeForm.control}
|
||||||
|
name="environmentSlug"
|
||||||
|
render={({ field: { onChange, ...field } }) => (
|
||||||
|
<FormControl label="Env">
|
||||||
|
<Select
|
||||||
|
{...field}
|
||||||
|
isDisabled={isMemberEditDisabled}
|
||||||
|
className="bg-mineshaft-600"
|
||||||
|
onValueChange={(e) => onChange(e)}
|
||||||
|
// className="w-full border border-mineshaft-500 bg-mineshaft-700 text-mineshaft-100"
|
||||||
|
>
|
||||||
|
{currentWorkspace?.environments?.map(({ slug, id }) => (
|
||||||
|
<SelectItem value={slug} key={id}>
|
||||||
|
{slug}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={privilegeForm.control}
|
||||||
|
name="secretPath"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl label="Secret Path">
|
||||||
|
<Input {...field} isDisabled={isMemberEditDisabled} className="w-48" />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-grow justify-between">
|
||||||
|
<Controller
|
||||||
|
control={privilegeForm.control}
|
||||||
|
name="read"
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<FormLabel label="View" className="mb-4" />
|
||||||
|
<Checkbox
|
||||||
|
isDisabled={isMemberEditDisabled}
|
||||||
|
id="secret-read"
|
||||||
|
className="h-5 w-5"
|
||||||
|
isChecked={field.value}
|
||||||
|
onCheckedChange={(isChecked) => field.onChange(isChecked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={privilegeForm.control}
|
||||||
|
name="create"
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<FormLabel label="Create" className="mb-4" />
|
||||||
|
<Checkbox
|
||||||
|
isDisabled={isMemberEditDisabled}
|
||||||
|
id="secret-create"
|
||||||
|
className="h-5 w-5"
|
||||||
|
isChecked={field.value}
|
||||||
|
onCheckedChange={(isChecked) => field.onChange(isChecked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={privilegeForm.control}
|
||||||
|
name="edit"
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<FormLabel label="Modify" className="mb-4" />
|
||||||
|
<Checkbox
|
||||||
|
isDisabled={isMemberEditDisabled}
|
||||||
|
id="secret-modify"
|
||||||
|
className="h-5 w-5"
|
||||||
|
isChecked={field.value}
|
||||||
|
onCheckedChange={(isChecked) => field.onChange(isChecked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={privilegeForm.control}
|
||||||
|
name="delete"
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<FormLabel label="Delete" className="mb-4" />
|
||||||
|
<Checkbox
|
||||||
|
isDisabled={isMemberEditDisabled}
|
||||||
|
id="secret-delete"
|
||||||
|
className="h-5 w-5"
|
||||||
|
isChecked={field.value}
|
||||||
|
onCheckedChange={(isChecked) => field.onChange(isChecked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-7 flex items-center space-x-2">
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger disabled={isMemberEditDisabled}>
|
||||||
|
<Tooltip
|
||||||
|
asChild
|
||||||
|
content={isExpired ? "Timed access expired" : "Grant timed access"}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline_bg"
|
||||||
|
isDisabled={isMemberEditDisabled}
|
||||||
|
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
|
||||||
|
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
|
||||||
|
className={twMerge(
|
||||||
|
"border-none py-1.5 capitalize",
|
||||||
|
isTemporary && "text-primary",
|
||||||
|
isExpired && "text-red-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getAccessLabel()}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</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={privilegeForm.control}
|
||||||
|
defaultValue="1h"
|
||||||
|
name="temporaryAccess.temporaryRange"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label={<TtlFormLabel label="Validity" />}
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
onClick={() => {
|
||||||
|
const temporaryRange = privilegeForm.getValues(
|
||||||
|
"temporaryAccess.temporaryRange"
|
||||||
|
);
|
||||||
|
if (!temporaryRange) {
|
||||||
|
privilegeForm.setError(
|
||||||
|
"temporaryAccess.temporaryRange",
|
||||||
|
{ type: "required", message: "Required" },
|
||||||
|
{ shouldFocus: true }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
privilegeForm.clearErrors("temporaryAccess.temporaryRange");
|
||||||
|
privilegeForm.setValue(
|
||||||
|
"temporaryAccess",
|
||||||
|
{
|
||||||
|
isTemporary: true,
|
||||||
|
temporaryAccessStartTime: new Date().toISOString(),
|
||||||
|
temporaryRange,
|
||||||
|
temporaryAccessEndTime: new Date(
|
||||||
|
new Date().getTime() + ms(temporaryRange)
|
||||||
|
).toISOString()
|
||||||
|
},
|
||||||
|
{ shouldDirty: true }
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{temporaryAccessField.isTemporary ? "Restart" : "Grant"}
|
||||||
|
</Button>
|
||||||
|
{temporaryAccessField.isTemporary && (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="outline_bg"
|
||||||
|
colorSchema="danger"
|
||||||
|
onClick={() => {
|
||||||
|
privilegeForm.setValue("temporaryAccess", {
|
||||||
|
isTemporary: false
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Revoke Access
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
{privilegeForm.formState.isDirty ? (
|
||||||
|
<>
|
||||||
|
<Tooltip content={isMemberEditDisabled ? "Access restricted" : "Save"}>
|
||||||
|
<IconButton
|
||||||
|
isDisabled={isMemberEditDisabled}
|
||||||
|
className="border-none py-2.5"
|
||||||
|
ariaLabel="save-privilege"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{privilegeForm.formState.isSubmitting ? (
|
||||||
|
<Spinner size="sm" />
|
||||||
|
) : (
|
||||||
|
<FontAwesomeIcon icon={faSave} />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Cancel">
|
||||||
|
<IconButton
|
||||||
|
variant="outline_bg"
|
||||||
|
className="border-none bg-mineshaft-600 py-2.5"
|
||||||
|
ariaLabel="delete-privilege"
|
||||||
|
isDisabled={privilegeForm.formState.isSubmitting}
|
||||||
|
onClick={() => privilegeForm.reset()}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faCancel} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Tooltip content={isMemberEditDisabled ? "Access restricted" : "Delete"}>
|
||||||
|
<IconButton
|
||||||
|
isDisabled={isMemberEditDisabled}
|
||||||
|
variant="outline_bg"
|
||||||
|
className="border-none bg-mineshaft-600 py-2.5"
|
||||||
|
ariaLabel="delete-privilege"
|
||||||
|
onClick={() => handlePopUpOpen("deletePrivilege")}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faClose} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<DeleteActionModal
|
||||||
|
isOpen={popUp.deletePrivilege.isOpen}
|
||||||
|
title="Remove user additional privilege"
|
||||||
|
onChange={(isOpen) => handlePopUpToggle("deletePrivilege", isOpen)}
|
||||||
|
deleteKey="delete"
|
||||||
|
onClose={() => handlePopUpClose("deletePrivilege")}
|
||||||
|
onDeleteApproved={handleDeletePrivilege}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
membershipId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SpecificPrivilegeSection = ({ membershipId }: Props) => {
|
||||||
|
const { data: userPrivileges, isLoading } = useListProjectUserPrivileges(membershipId);
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
|
||||||
|
const createUserPrivilege = useCreateProjectUserAdditionalPrivilege();
|
||||||
|
|
||||||
|
const handleCreatePrivilege = async () => {
|
||||||
|
if (createUserPrivilege.isLoading) return;
|
||||||
|
try {
|
||||||
|
await createUserPrivilege.mutateAsync({
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
action: ProjectPermissionActions.Read,
|
||||||
|
subject: [ProjectPermissionSub.Secrets],
|
||||||
|
conditions: {
|
||||||
|
environment: currentWorkspace?.environments?.[0].slug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
projectMembershipId: membershipId
|
||||||
|
});
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
text: "Successfully created privilege"
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "Failed to create privilege"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6 border-t border-t-gray-700 pt-6">
|
||||||
|
<div className="text-lg font-medium flex items-center space-x-2">
|
||||||
|
Additional Privileges
|
||||||
|
{isLoading && <Spinner size="xs" />}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-mineshaft-400">
|
||||||
|
Select individual privileges to associate with the user
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
{userPrivileges
|
||||||
|
?.filter(({ permissions }) =>
|
||||||
|
permissions?.[0]?.subject?.includes(ProjectPermissionSub.Secrets)
|
||||||
|
)
|
||||||
|
?.map((privilege) => (
|
||||||
|
<SpecificPrivilegeSecretForm
|
||||||
|
privilege={privilege as TProjectUserPrivilege}
|
||||||
|
key={privilege?.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Member}>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<Button
|
||||||
|
variant="outline_bg"
|
||||||
|
className="mt-4"
|
||||||
|
onClick={handleCreatePrivilege}
|
||||||
|
isLoading={createUserPrivilege.isLoading}
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
>
|
||||||
|
Add additional privilege
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { MemberRoleForm } from "./MemberRoleForm";
|
||||||
@@ -1,471 +0,0 @@
|
|||||||
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 { createNotification } from "@app/components/notifications";
|
|
||||||
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 { 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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user