mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-15 10:35:17 +00:00
Merge pull request #2321 from Infisical/daniel/specify-roles-and-projects
Feat: Select roles & projects when inviting members to organization
This commit is contained in:
@@ -2,7 +2,7 @@ const path = require("path");
|
||||
|
||||
const ContentSecurityPolicy = `
|
||||
default-src 'self';
|
||||
script-src 'self' https://*.posthog.com https://*.*.posthog.com https://js.stripe.com https://api.stripe.com https://widget.intercom.io https://js.intercomcdn.com https://hcaptcha.com https://*.hcaptcha.com 'unsafe-inline' 'unsafe-eval';
|
||||
script-src 'self' https://*.posthog.com https://js.stripe.com https://api.stripe.com https://widget.intercom.io https://js.intercomcdn.com https://hcaptcha.com https://*.hcaptcha.com 'unsafe-inline' 'unsafe-eval';
|
||||
style-src 'self' https://rsms.me 'unsafe-inline' https://hcaptcha.com https://*.hcaptcha.com;
|
||||
child-src https://api.stripe.com;
|
||||
frame-src https://js.stripe.com/ https://api.stripe.com https://www.youtube.com/ https://hcaptcha.com https://*.hcaptcha.com;
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { Fragment } from "react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
|
||||
import Button from "../buttons/Button";
|
||||
import InputField from "../InputField";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
closeModal: () => void;
|
||||
submitModal: (email: string) => void;
|
||||
email: string;
|
||||
setEmail: (email: string) => void;
|
||||
orgName: string;
|
||||
};
|
||||
|
||||
const AddUserDialog = ({ isOpen, closeModal, submitModal, email, setEmail, orgName }: Props) => {
|
||||
const submit = () => {
|
||||
submitModal(email);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="z-50">
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative" onClose={closeModal}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-70" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="w-full max-w-lg transform overflow-hidden rounded-md border border-gray-700 bg-bunker-800 p-6 text-left align-middle shadow-xl transition-all">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="z-50 text-lg font-medium leading-6 text-gray-400"
|
||||
>
|
||||
Invite others to {orgName}
|
||||
</Dialog.Title>
|
||||
<div className="mt-2 mb-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
An invite is specific to an email address and expires after 1 day. For
|
||||
security reasons, you will need to separately add members to projects.
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-h-28">
|
||||
<InputField
|
||||
label="Email"
|
||||
onChangeHandler={setEmail}
|
||||
type="varName"
|
||||
value={email}
|
||||
placeholder=""
|
||||
isRequired
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 max-w-max">
|
||||
<Button onButtonPressed={submit} color="mineshaft" text="Invite" size="md" />
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
{/* <Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-md bg-bunker-800 border border-gray-700 p-6 text-left align-middle shadow-xl transition-all">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="text-xl font-medium leading-6 text-gray-300 z-50"
|
||||
>
|
||||
Unleash Infisical's Full Power
|
||||
</Dialog.Title>
|
||||
<div className="mt-4 mb-4">
|
||||
<p className="text-sm text-gray-400 mb-2">
|
||||
You have exceeded the number of members in a free organization.
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
Upgrade now and get access to adding more members, as well as to other powerful enhancements.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex justify-center rounded-md border border-transparent bg-primary px-4 py-2 text-sm font-medium text-black hover:opacity-80 hover:text-semibold duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
|
||||
onClick={() => router.push("/settings/billing/" + router.query.id)}
|
||||
>
|
||||
Upgrade Now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-2 inline-flex justify-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-sm font-medium text-gray-400 hover:bg-gray-500 hover:text-black hover:text-semibold duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
|
||||
onClick={closeModal}
|
||||
>
|
||||
Maybe Later
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Panel> */}
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddUserDialog;
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { useAddUserToOrg } from "@app/hooks/api";
|
||||
import { useAddUsersToOrg } from "@app/hooks/api";
|
||||
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function TeamInviteStep(): JSX.Element {
|
||||
const [emails, setEmails] = useState("");
|
||||
const { data: serverDetails } = useFetchServerStatus();
|
||||
|
||||
const { mutateAsync } = useAddUserToOrg();
|
||||
const { mutateAsync } = useAddUsersToOrg();
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp(["setUpEmail"] as const);
|
||||
|
||||
// Redirect user to the getting started page
|
||||
@@ -31,8 +31,9 @@ export default function TeamInviteStep(): JSX.Element {
|
||||
.map((email) => email.trim())
|
||||
.map(async (email) => {
|
||||
mutateAsync({
|
||||
inviteeEmail: email,
|
||||
organizationId: String(localStorage.getItem("orgData.id"))
|
||||
inviteeEmails: [email],
|
||||
organizationId: String(localStorage.getItem("orgData.id")),
|
||||
organizationRoleSlug: "member"
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ export type CompleteAccountDTO = {
|
||||
salt: string;
|
||||
verifier: string;
|
||||
password: string;
|
||||
tokenMetadata?: string;
|
||||
};
|
||||
|
||||
export type CompleteAccountSignupDTO = CompleteAccountDTO & {
|
||||
|
||||
@@ -47,7 +47,7 @@ export const roleQueryKeys = {
|
||||
["user-project-permissions", { workspaceId }] as const
|
||||
};
|
||||
|
||||
const getProjectRoles = async (projectId: string) => {
|
||||
export const getProjectRoles = async (projectId: string) => {
|
||||
const { data } = await apiRequest.get<{ roles: Array<Omit<TProjectRole, "permissions">> }>(
|
||||
`/api/v1/workspace/${projectId}/roles`
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ export {
|
||||
} from "./mutation";
|
||||
export {
|
||||
fetchOrgUsers,
|
||||
useAddUserToOrg,
|
||||
useAddUsersToOrg,
|
||||
useCreateAPIKey,
|
||||
useDeleteAPIKey,
|
||||
useDeleteMe,
|
||||
@@ -26,4 +26,5 @@ export {
|
||||
useRevokeMySessions,
|
||||
useUpdateMfaEnabled,
|
||||
useUpdateOrgMembership,
|
||||
useUpdateUserAuthMethods} from "./queries";
|
||||
useUpdateUserAuthMethods
|
||||
} from "./queries";
|
||||
|
||||
@@ -157,12 +157,15 @@ export const useGetOrgUsers = (orgId: string) =>
|
||||
|
||||
// mutation
|
||||
// TODO(akhilmhdh): move all mutation to mutation file
|
||||
export const useAddUserToOrg = () => {
|
||||
export const useAddUsersToOrg = () => {
|
||||
const queryClient = useQueryClient();
|
||||
type Response = {
|
||||
data: {
|
||||
message: string;
|
||||
completeInviteLink: string | undefined;
|
||||
completeInviteLinks?: {
|
||||
email: string;
|
||||
link: string;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -149,7 +149,10 @@ export type DeletOrgMembershipDTO = {
|
||||
};
|
||||
|
||||
export type AddUserToOrgDTO = {
|
||||
inviteeEmail: string;
|
||||
inviteeEmails: string[];
|
||||
projectIds?: string[];
|
||||
projectRoleSlug?: string;
|
||||
organizationRoleSlug: string;
|
||||
organizationId: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -138,8 +138,12 @@ export const useGetUpgradeProjectStatus = ({
|
||||
});
|
||||
};
|
||||
|
||||
const fetchUserWorkspaces = async () => {
|
||||
const { data } = await apiRequest.get<{ workspaces: Workspace[] }>("/api/v1/workspace");
|
||||
const fetchUserWorkspaces = async (includeRoles?: boolean) => {
|
||||
const { data } = await apiRequest.get<{ workspaces: Workspace[] }>("/api/v1/workspace", {
|
||||
params: {
|
||||
includeRoles
|
||||
}
|
||||
});
|
||||
return data.workspaces;
|
||||
};
|
||||
|
||||
@@ -171,8 +175,8 @@ export const useGetWorkspaceById = (
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetUserWorkspaces = () =>
|
||||
useQuery(workspaceKeys.getAllUserWorkspace, fetchUserWorkspaces);
|
||||
export const useGetUserWorkspaces = (includeRoles?: boolean) =>
|
||||
useQuery(workspaceKeys.getAllUserWorkspace, () => fetchUserWorkspaces(includeRoles));
|
||||
|
||||
const fetchUserWorkspaceMemberships = async (orgId: string) => {
|
||||
const { data } = await apiRequest.get<Record<string, Workspace[]>>(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { TProjectRole } from "../roles/types";
|
||||
|
||||
export enum ProjectVersion {
|
||||
V1 = 1,
|
||||
V2 = 2,
|
||||
@@ -22,6 +24,8 @@ export type Workspace = {
|
||||
auditLogsRetentionDays: number;
|
||||
slug: string;
|
||||
createdAt: string;
|
||||
|
||||
roles?: TProjectRole[];
|
||||
};
|
||||
|
||||
export type WorkspaceEnv = {
|
||||
|
||||
@@ -8,6 +8,7 @@ type UseToggleReturn = [
|
||||
on: VoidFn;
|
||||
off: VoidFn;
|
||||
toggle: VoidFn;
|
||||
timedToggle: (timeout?: number) => void;
|
||||
}
|
||||
];
|
||||
|
||||
@@ -26,5 +27,13 @@ export const useToggle = (initialState = false): UseToggleReturn => {
|
||||
setValue((prev) => (typeof isOpen === "boolean" ? isOpen : !prev));
|
||||
}, []);
|
||||
|
||||
return [value, { on, off, toggle }];
|
||||
const timedToggle = useCallback((timeout = 2000) => {
|
||||
setValue((prev) => !prev);
|
||||
|
||||
setTimeout(() => {
|
||||
setValue(false);
|
||||
}, timeout);
|
||||
}, []);
|
||||
|
||||
return [value, { on, off, toggle, timedToggle }];
|
||||
};
|
||||
|
||||
@@ -38,6 +38,7 @@ export default function LoginPage() {
|
||||
const { user, isLoading: userLoading } = useUser();
|
||||
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
const callbackPort = queryParams.get("callback_port");
|
||||
|
||||
const logout = useLogoutUser(true);
|
||||
const handleLogout = useCallback(async () => {
|
||||
@@ -52,8 +53,6 @@ export default function LoginPage() {
|
||||
|
||||
const handleSelectOrganization = useCallback(
|
||||
async (organization: Organization) => {
|
||||
const callbackPort = queryParams.get("callback_port");
|
||||
|
||||
if (organization.authEnforced) {
|
||||
// org has an org-level auth method enabled (e.g. SAML)
|
||||
// -> logout + redirect to SAML SSO
|
||||
@@ -116,9 +115,8 @@ export default function LoginPage() {
|
||||
[selectOrg]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCliRedirect = useCallback(() => {
|
||||
const authToken = getAuthToken();
|
||||
const callbackPort = queryParams.get("callback_port");
|
||||
|
||||
if (authToken && !callbackPort) {
|
||||
const decodedJwt = jwt_decode(authToken) as any;
|
||||
@@ -131,13 +129,27 @@ export default function LoginPage() {
|
||||
if (!isLoggedIn()) {
|
||||
router.push("/login");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (callbackPort) {
|
||||
handleCliRedirect();
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
// Case: User has no organizations.
|
||||
// This can happen if the user was previously a member, but the organization was deleted or the user was removed.
|
||||
useEffect(() => {
|
||||
if (!organizations.isLoading && organizations.data?.length === 0) {
|
||||
if (organizations.isLoading || !organizations.data) return;
|
||||
|
||||
if (organizations.data.length === 0) {
|
||||
router.push("/org/none");
|
||||
} else if (organizations.data.length === 1) {
|
||||
if (callbackPort) {
|
||||
handleCliRedirect();
|
||||
} else {
|
||||
handleSelectOrganization(organizations.data[0]);
|
||||
}
|
||||
}
|
||||
}, [organizations.isLoading, organizations.data]);
|
||||
|
||||
|
||||
@@ -64,6 +64,10 @@ export default function SignupInvite() {
|
||||
const email = (parsedUrl.to as string)?.replace(" ", "+").trim();
|
||||
const { config } = useServerConfig();
|
||||
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
|
||||
const metadata = queryParams.get("metadata") || undefined;
|
||||
|
||||
const { mutateAsync: selectOrganization } = useSelectOrganization();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -160,7 +164,8 @@ export default function SignupInvite() {
|
||||
encryptedPrivateKeyIV,
|
||||
encryptedPrivateKeyTag,
|
||||
salt: result.salt,
|
||||
verifier: result.verifier
|
||||
verifier: result.verifier,
|
||||
tokenMetadata: metadata
|
||||
});
|
||||
|
||||
// unset temporary signup JWT token and set JWT token
|
||||
|
||||
+275
-56
@@ -1,65 +1,144 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faCheckCircle,
|
||||
faChevronDown,
|
||||
faExclamationCircle
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, IconButton, Input, Modal, ModalContent } from "@app/components/v2";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
FormControl,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem,
|
||||
TextArea,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { useAddUserToOrg, useFetchServerStatus } from "@app/hooks/api";
|
||||
import {
|
||||
useAddUsersToOrg,
|
||||
useFetchServerStatus,
|
||||
useGetOrgRoles,
|
||||
useGetUserWorkspaces
|
||||
} from "@app/hooks/api";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { ProjectVersion } from "@app/hooks/api/workspace/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const addMemberFormSchema = yup.object({
|
||||
email: yup.string().email().required().label("Email").trim().lowercase()
|
||||
import { OrgInviteLink } from "./OrgInviteLink";
|
||||
|
||||
const DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG = "member";
|
||||
|
||||
const EmailSchema = z.string().email().min(1).trim().toLowerCase();
|
||||
|
||||
const addMemberFormSchema = z.object({
|
||||
emails: z.string().min(1).trim().toLowerCase(),
|
||||
projectIds: z.array(z.string().min(1).trim().toLowerCase()).default([]),
|
||||
projectRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG),
|
||||
organizationRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG)
|
||||
});
|
||||
|
||||
type TAddMemberForm = yup.InferType<typeof addMemberFormSchema>;
|
||||
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["addMember"]>;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addMember"]>, state?: boolean) => void;
|
||||
completeInviteLink: string;
|
||||
setCompleteInviteLink: (link: string) => void;
|
||||
completeInviteLinks: Array<{
|
||||
email: string;
|
||||
link: string;
|
||||
}> | null;
|
||||
setCompleteInviteLinks: (links: Array<{ email: string; link: string }> | null) => void;
|
||||
};
|
||||
|
||||
export const AddOrgMemberModal = ({
|
||||
popUp,
|
||||
handlePopUpToggle,
|
||||
completeInviteLink,
|
||||
setCompleteInviteLink
|
||||
completeInviteLinks,
|
||||
setCompleteInviteLinks
|
||||
}: Props) => {
|
||||
|
||||
const { currentOrg } = useOrganization();
|
||||
|
||||
const { data: organizationRoles } = useGetOrgRoles(currentOrg?.id ?? "");
|
||||
const { data: serverDetails } = useFetchServerStatus();
|
||||
const { mutateAsync: addUserMutateAsync } = useAddUserToOrg();
|
||||
|
||||
const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false);
|
||||
const { mutateAsync: addUsersMutateAsync } = useAddUsersToOrg();
|
||||
const { data: projects } = useGetUserWorkspaces(true);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
watch,
|
||||
reset,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<TAddMemberForm>({ resolver: yupResolver(addMemberFormSchema) });
|
||||
} = useForm<TAddMemberForm>({ resolver: zodResolver(addMemberFormSchema) });
|
||||
|
||||
const onAddMember = async ({ email }: TAddMemberForm) => {
|
||||
const selectedProjectIds = watch("projectIds", []);
|
||||
|
||||
const onAddMembers = async ({
|
||||
emails,
|
||||
organizationRoleSlug,
|
||||
projectIds,
|
||||
projectRoleSlug
|
||||
}: TAddMemberForm) => {
|
||||
if (!currentOrg?.id) return;
|
||||
|
||||
const selectedProjects = projects?.filter((project) => projectIds.includes(String(project.id)));
|
||||
|
||||
if (selectedProjects?.length) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const project of selectedProjects) {
|
||||
if (project.version !== ProjectVersion.V3) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: `Cannot add users to project "${project.name}" because it's incompatible. Please upgrade the project.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await addUserMutateAsync({
|
||||
const parsedEmails = emails
|
||||
.replace(/\s/g, "")
|
||||
.split(",")
|
||||
.map((email) => {
|
||||
if (EmailSchema.safeParse(email).success) {
|
||||
return email.trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
if (parsedEmails.includes(null)) {
|
||||
createNotification({
|
||||
text: "Invalid email addresses provided.",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = await addUsersMutateAsync({
|
||||
organizationId: currentOrg?.id,
|
||||
inviteeEmail: email
|
||||
inviteeEmails: emails.split(",").map((email) => email.trim()),
|
||||
organizationRoleSlug,
|
||||
projectIds,
|
||||
projectRoleSlug
|
||||
});
|
||||
|
||||
setCompleteInviteLink(data?.completeInviteLink ?? "");
|
||||
setCompleteInviteLinks(data?.completeInviteLinks ?? null);
|
||||
|
||||
// only show this notification when email is configured.
|
||||
// A [completeInviteLink] will not be sent if smtp is configured
|
||||
|
||||
if (!data.completeInviteLink) {
|
||||
if (!data.completeInviteLinks) {
|
||||
createNotification({
|
||||
text: "Successfully invited user to the organization.",
|
||||
type: "success"
|
||||
@@ -80,47 +159,196 @@ export const AddOrgMemberModal = ({
|
||||
reset();
|
||||
};
|
||||
|
||||
const copyTokenToClipboard = () => {
|
||||
navigator.clipboard.writeText(completeInviteLink as string);
|
||||
setInviteLinkCopied.on();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.addMember?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("addMember", isOpen);
|
||||
setCompleteInviteLink("");
|
||||
setCompleteInviteLinks(null);
|
||||
}}
|
||||
>
|
||||
<ModalContent
|
||||
title={`Invite others to ${currentOrg?.name}`}
|
||||
subTitle={
|
||||
<div>
|
||||
{!completeInviteLink && (
|
||||
<div>
|
||||
An invite is specific to an email address and expires after 1 day.
|
||||
<br />
|
||||
For security reasons, you will need to separately add members to projects.
|
||||
</div>
|
||||
{!completeInviteLinks && (
|
||||
<div>An invite is specific to an email address and expires after 1 day.</div>
|
||||
)}
|
||||
{completeInviteLink &&
|
||||
{completeInviteLinks &&
|
||||
"This Infisical instance does not have a email provider setup. Please share this invite link with the invitee manually"}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{!completeInviteLink && (
|
||||
<form onSubmit={handleSubmit(onAddMember)}>
|
||||
{!completeInviteLinks && (
|
||||
<form onSubmit={handleSubmit(onAddMembers)}>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="email"
|
||||
name="emails"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Email" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input {...field} />
|
||||
<FormControl label="Emails" isError={Boolean(error)} errorText={error?.message}>
|
||||
<TextArea
|
||||
{...field}
|
||||
className="mt-1 h-20 w-full min-w-[30rem] rounded-md border border-mineshaft-500 bg-mineshaft-900/70 py-1 px-2 text-sm text-bunker-300 outline-none ring-primary-800 ring-opacity-70 transition-all placeholder:text-bunker-400 focus:ring-2"
|
||||
placeholder="[email protected], [email protected]..."
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="organizationRoleSlug"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText="Select which organization role you want to assign to the user."
|
||||
label="Assign organization role"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<div>
|
||||
<Select
|
||||
className="w-full"
|
||||
defaultValue={DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG}
|
||||
{...field}
|
||||
onValueChange={(val) => field.onChange(val)}
|
||||
>
|
||||
{organizationRoles?.map((role) => (
|
||||
<SelectItem key={role.id} value={role.slug}>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={control}
|
||||
name="projectIds"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Assign users to projects (optional)"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{projects && projects.length > 0 ? (
|
||||
<div className="inline-flex w-full cursor-pointer items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{selectedProjectIds.length === 1
|
||||
? projects.find((project) => project.id === selectedProjectIds[0])
|
||||
?.name
|
||||
: selectedProjectIds.length === 0
|
||||
? "No projects selected"
|
||||
: `${selectedProjectIds.length} projects selected`}
|
||||
<FontAwesomeIcon icon={faChevronDown} className="text-xs" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="inline-flex w-full cursor-default items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
|
||||
No projects found
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="thin-scrollbar z-[100] max-h-80"
|
||||
>
|
||||
{projects && projects.length > 0 ? (
|
||||
projects.map((project) => {
|
||||
const isSelected = selectedProjectIds.includes(String(project.id));
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) =>
|
||||
projects.length > 1 && event.preventDefault()
|
||||
}
|
||||
onClick={() => {
|
||||
if (selectedProjectIds.includes(String(project.id))) {
|
||||
field.onChange(
|
||||
selectedProjectIds.filter(
|
||||
(projectId: string) => projectId !== String(project.id)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
field.onChange([...selectedProjectIds, String(project.id)]);
|
||||
}
|
||||
}}
|
||||
key={`project-id-${project.id}`}
|
||||
icon={
|
||||
isSelected ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faCheckCircle}
|
||||
className="pr-0.5 text-primary"
|
||||
/>
|
||||
) : (
|
||||
<div className="pl-[1.01rem]" />
|
||||
)
|
||||
}
|
||||
iconPos="left"
|
||||
className="w-[28.4rem] text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2 capitalize">
|
||||
{project.name}
|
||||
{project.version !== ProjectVersion.V3 && (
|
||||
<Tooltip content="Project is not compatible with this action, please upgrade this project.">
|
||||
<FontAwesomeIcon
|
||||
icon={faExclamationCircle}
|
||||
className="text-xs opacity-50"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-fit justify-end">
|
||||
<Controller
|
||||
control={control}
|
||||
name="projectRoleSlug"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText="Select which role to assign to the users in the selected projects."
|
||||
label="Role"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<div>
|
||||
<Select
|
||||
isDisabled={selectedProjectIds.length === 0}
|
||||
defaultValue={DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG}
|
||||
{...field}
|
||||
onValueChange={(val) => field.onChange(val)}
|
||||
>
|
||||
{Object.entries(ProjectMembershipRole).map(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
([_, slug]) =>
|
||||
slug !== "custom" && (
|
||||
<SelectItem key={slug} value={slug}>
|
||||
<span className="capitalize">{slug.replace("-", " ")}</span>
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</Select>
|
||||
</div>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
@@ -141,20 +369,11 @@ export const AddOrgMemberModal = ({
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{completeInviteLink && (
|
||||
<div className="mt-2 mb-3 mr-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
|
||||
<p className="mr-4 break-all">{completeInviteLink}</p>
|
||||
<IconButton
|
||||
ariaLabel="copy icon"
|
||||
colorSchema="secondary"
|
||||
className="group relative"
|
||||
onClick={copyTokenToClipboard}
|
||||
>
|
||||
<FontAwesomeIcon icon={isInviteLinkCopied ? faCheck : faCopy} />
|
||||
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
|
||||
click to copy
|
||||
</span>
|
||||
</IconButton>
|
||||
{completeInviteLinks && (
|
||||
<div className="space-y-3">
|
||||
{completeInviteLinks.map((invite) => (
|
||||
<OrgInviteLink key={`invite-${invite.email}`} invite={invite} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ModalContent>
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { IconButton, Tooltip } from "@app/components/v2";
|
||||
import { useToggle } from "@app/hooks";
|
||||
|
||||
type Props = {
|
||||
invite: { email: string; link: string };
|
||||
};
|
||||
|
||||
export const OrgInviteLink = ({ invite }: Props) => {
|
||||
const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false);
|
||||
|
||||
const copyTokenToClipboard = () => {
|
||||
if (isInviteLinkCopied) return;
|
||||
|
||||
navigator.clipboard.writeText(invite.link);
|
||||
setInviteLinkCopied.timedToggle();
|
||||
|
||||
createNotification({
|
||||
type: "info",
|
||||
text: "Copied invitation link to clipboard"
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={`invite-${invite.email}`}>
|
||||
<p className="text-sm text-mineshaft-400">
|
||||
Invite for <span className="font-medium">{invite.email}</span>
|
||||
</p>
|
||||
<div className="flex flex-col gap-1 rounded-md bg-white/[0.04] p-2 text-base text-gray-400">
|
||||
<p className="line-clamp-1 mr-4 overflow-hidden text-ellipsis whitespace-nowrap ">
|
||||
{invite.link}
|
||||
</p>
|
||||
<Tooltip content={`Copy invitation link for ${invite.email}`}>
|
||||
<IconButton
|
||||
ariaLabel="copy icon"
|
||||
colorSchema="secondary"
|
||||
className="group relative"
|
||||
onClick={() => copyTokenToClipboard()}
|
||||
>
|
||||
<FontAwesomeIcon icon={isInviteLinkCopied ? faCheck : faCopy} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+7
-4
@@ -27,7 +27,10 @@ export const OrgMembersSection = () => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id ?? "";
|
||||
|
||||
const [completeInviteLink, setCompleteInviteLink] = useState<string>("");
|
||||
const [completeInviteLinks, setCompleteInviteLinks] = useState<Array<{
|
||||
email: string;
|
||||
link: string;
|
||||
}> | null>(null);
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"addMember",
|
||||
@@ -132,13 +135,13 @@ export const OrgMembersSection = () => {
|
||||
</div>
|
||||
<OrgMembersTable
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
setCompleteInviteLink={setCompleteInviteLink}
|
||||
setCompleteInviteLinks={setCompleteInviteLinks}
|
||||
/>
|
||||
<AddOrgMemberModal
|
||||
popUp={popUp}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
completeInviteLink={completeInviteLink}
|
||||
setCompleteInviteLink={setCompleteInviteLink}
|
||||
completeInviteLinks={completeInviteLinks}
|
||||
setCompleteInviteLinks={setCompleteInviteLinks}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeMember.isOpen}
|
||||
|
||||
+9
-8
@@ -33,7 +33,7 @@ import {
|
||||
useUser
|
||||
} from "@app/context";
|
||||
import {
|
||||
useAddUserToOrg,
|
||||
useAddUsersToOrg,
|
||||
useFetchServerStatus,
|
||||
useGetOrgRoles,
|
||||
useGetOrgUsers,
|
||||
@@ -50,10 +50,10 @@ type Props = {
|
||||
description?: string;
|
||||
}
|
||||
) => void;
|
||||
setCompleteInviteLink: (link: string) => void;
|
||||
setCompleteInviteLinks: (links: Array<{ email: string; link: string }> | null) => void;
|
||||
};
|
||||
|
||||
export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Props) => {
|
||||
export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Props) => {
|
||||
const router = useRouter();
|
||||
const { subscription } = useSubscription();
|
||||
const { currentOrg } = useOrganization();
|
||||
@@ -68,7 +68,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
|
||||
const { data: serverDetails } = useFetchServerStatus();
|
||||
const { data: members, isLoading: isMembersLoading } = useGetOrgUsers(orgId);
|
||||
|
||||
const { mutateAsync: addUserMutateAsync } = useAddUserToOrg();
|
||||
const { mutateAsync: addUsersMutateAsync } = useAddUsersToOrg();
|
||||
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
|
||||
|
||||
const onRoleChange = async (membershipId: string, role: string) => {
|
||||
@@ -106,14 +106,15 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
|
||||
|
||||
const onResendInvite = async (email: string) => {
|
||||
try {
|
||||
const { data } = await addUserMutateAsync({
|
||||
const { data } = await addUsersMutateAsync({
|
||||
organizationId: orgId,
|
||||
inviteeEmail: email
|
||||
inviteeEmails: [email],
|
||||
organizationRoleSlug: "member"
|
||||
});
|
||||
|
||||
setCompleteInviteLink(data?.completeInviteLink || "");
|
||||
setCompleteInviteLinks(data?.completeInviteLinks || null);
|
||||
|
||||
if (!data.completeInviteLink) {
|
||||
if (!data.completeInviteLinks) {
|
||||
createNotification({
|
||||
text: `Successfully resent invite to ${email}`,
|
||||
type: "success"
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from "@app/context";
|
||||
import { useTimedReset } from "@app/hooks";
|
||||
import {
|
||||
useAddUserToOrg,
|
||||
useAddUsersToOrg,
|
||||
useFetchServerStatus,
|
||||
useGetOrgMembership,
|
||||
useGetOrgRoles
|
||||
@@ -44,18 +44,19 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) =>
|
||||
const { data: roles } = useGetOrgRoles(orgId);
|
||||
const { data: serverDetails } = useFetchServerStatus();
|
||||
const { data: membership } = useGetOrgMembership(orgId, membershipId);
|
||||
const { mutateAsync: inviteUser, isLoading } = useAddUserToOrg();
|
||||
const { mutateAsync: inviteUsers, isLoading } = useAddUsersToOrg();
|
||||
|
||||
const onResendInvite = async (email: string) => {
|
||||
try {
|
||||
const { data } = await inviteUser({
|
||||
const { data } = await inviteUsers({
|
||||
organizationId: orgId,
|
||||
inviteeEmail: email
|
||||
inviteeEmails: [email],
|
||||
organizationRoleSlug: "member"
|
||||
});
|
||||
|
||||
// setCompleteInviteLink(data?.completeInviteLink || "");
|
||||
|
||||
if (!data.completeInviteLink) {
|
||||
if (!data.completeInviteLinks) {
|
||||
createNotification({
|
||||
text: `Successfully resent invite to ${email}`,
|
||||
type: "success"
|
||||
|
||||
Reference in New Issue
Block a user