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:
Daniel Hougaard
2024-08-26 15:00:39 +04:00
committed by GitHub
38 changed files with 1095 additions and 501 deletions
@@ -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"
});
});
+1
View File
@@ -93,6 +93,7 @@ export type CompleteAccountDTO = {
salt: string;
verifier: string;
password: string;
tokenMetadata?: string;
};
export type CompleteAccountSignupDTO = CompleteAccountDTO & {
+1 -1
View File
@@ -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`
);
+3 -2
View File
@@ -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";
+5 -2
View File
@@ -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;
}[];
};
};
+4 -1
View File
@@ -149,7 +149,10 @@ export type DeletOrgMembershipDTO = {
};
export type AddUserToOrgDTO = {
inviteeEmail: string;
inviteeEmails: string[];
projectIds?: string[];
projectRoleSlug?: string;
organizationRoleSlug: string;
organizationId: string;
};
+8 -4
View File
@@ -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 = {
+10 -1
View File
@@ -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]);
+6 -1
View File
@@ -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
@@ -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>
@@ -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>
);
};
@@ -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}
@@ -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"