From c1c47f9a6d9239951e74cdf50e4744dbe2dd86c0 Mon Sep 17 00:00:00 2001 From: Philippe Rolet Date: Tue, 17 Oct 2023 13:55:18 +0200 Subject: [PATCH] Admin member improvements (#2142) * Code improvements * Close invitation revocation modal after click * Loading visual to load members * Change role modal with barheader buttons + processing logic while mutating * Fixes lowercase in search * spolu coms --- front/package-lock.json | 8 +- front/package.json | 2 +- front/pages/w/[wId]/members/index.tsx | 302 +++++++++++++++----------- 3 files changed, 182 insertions(+), 130 deletions(-) diff --git a/front/package-lock.json b/front/package-lock.json index 770b563f64ca..5e474036e004 100644 --- a/front/package-lock.json +++ b/front/package-lock.json @@ -5,7 +5,7 @@ "packages": { "": { "dependencies": { - "@dust-tt/sparkle": "^0.2.20", + "@dust-tt/sparkle": "^0.2.21", "@emoji-mart/data": "^1.1.2", "@emoji-mart/react": "^1.1.1", "@headlessui/react": "^1.7.7", @@ -859,9 +859,9 @@ "integrity": "sha512-smLocSfrt3s53H/XSVP3/1kP42oqvrkjUPtyaFd1F79ux24oE31BKt+q0c6lsa6hOYrFzsIwyc5GXAI5JmfOew==" }, "node_modules/@dust-tt/sparkle": { - "version": "0.2.20", - "resolved": "https://registry.npmjs.org/@dust-tt/sparkle/-/sparkle-0.2.20.tgz", - "integrity": "sha512-MiH5EYa9T/Nl8tLZd2zgGRJjk3BBaXWpcNCGa3HQ8Arv/s4OrB0SYPv1z+GVdnS/+R44pD8WG860JlT6uCQ10Q==", + "version": "0.2.21", + "resolved": "https://registry.npmjs.org/@dust-tt/sparkle/-/sparkle-0.2.21.tgz", + "integrity": "sha512-8LWXIYvDkHS4LlXKqVbNxwwZBDoSIIN+d7+w0Ep2JTvo6RBdQQGQryLHLZzqznPK0Py+MvoKG/vlukOESby9NA==", "dependencies": { "@headlessui/react": "^1.7.17" }, diff --git a/front/package.json b/front/package.json index ebb653ef5bb3..6caa1db4001c 100644 --- a/front/package.json +++ b/front/package.json @@ -13,7 +13,7 @@ "initdb": "env $(cat .env.local) npx tsx admin/db.ts" }, "dependencies": { - "@dust-tt/sparkle": "^0.2.20", + "@dust-tt/sparkle": "^0.2.21", "@emoji-mart/data": "^1.1.2", "@emoji-mart/react": "^1.1.1", "@headlessui/react": "^1.7.7", diff --git a/front/pages/w/[wId]/members/index.tsx b/front/pages/w/[wId]/members/index.tsx index ab737d00bafe..f171f96240fa 100644 --- a/front/pages/w/[wId]/members/index.tsx +++ b/front/pages/w/[wId]/members/index.tsx @@ -26,7 +26,6 @@ import { getUserFromSession, RoleType, } from "@app/lib/auth"; -import { ModelId } from "@app/lib/databases"; import { useMembers, useWorkspaceInvitations } from "@app/lib/swr"; import { classNames, isEmailValid } from "@app/lib/utils"; import { MembershipInvitationType } from "@app/types/membership_invitation"; @@ -156,22 +155,30 @@ export default function WorkspaceAdmin({ user: "emerald", }; const [searchText, setSearchText] = useState(""); - const { members } = useMembers(owner); - const { invitations } = useWorkspaceInvitations(owner); + const { members, isMembersLoading } = useMembers(owner); + const { invitations, isInvitationsLoading } = + useWorkspaceInvitations(owner); const [inviteEmailModalOpen, setInviteEmailModalOpen] = useState(false); /** Modal for changing member role: we need to use 2 states: set the member * first, then open the modal with an unoticeable delay. Using * only 1 state for both would break the modal animation because rerendering * at the same time than switching modal to open*/ const [changeRoleModalOpen, setChangeRoleModalOpen] = useState(false); - const [changeRoleMemberId, setChangeRoleMemberId] = - useState(null); + const [changeRoleMember, setChangeRoleMember] = useState( + null + ); /* Same for invitations modal */ const [revokeInvitationModalOpen, setRevokeInvitationModalOpen] = useState(false); const [invitationToRevoke, setInvitationToRevoke] = useState(null); + function isInvitation( + arg: MembershipInvitationType | UserType + ): arg is MembershipInvitationType { + return (arg as MembershipInvitationType).inviteEmail !== undefined; + } + const displayedMembersAndInvitations: ( | UserType | MembershipInvitationType @@ -182,15 +189,17 @@ export default function WorkspaceAdmin({ .filter( (m) => !searchText || - m.name.toLowerCase().includes(searchText) || - m.email?.toLowerCase().includes(searchText) || - m.username?.toLowerCase().includes(searchText) + m.name.toLowerCase().includes(searchText.toLowerCase()) || + m.email?.toLowerCase().includes(searchText.toLowerCase()) || + m.username?.toLowerCase().includes(searchText.toLowerCase()) ), ...invitations .sort((a, b) => a.inviteEmail.localeCompare(b.inviteEmail)) .filter((i) => i.status === "pending") .filter( - (i) => !searchText || i.inviteEmail.toLowerCase().includes(searchText) + (i) => + !searchText || + i.inviteEmail.toLowerCase().includes(searchText.toLowerCase()) ), ]; @@ -211,7 +220,7 @@ export default function WorkspaceAdmin({ /> m.id === changeRoleMemberId) || null} + member={changeRoleMember} onClose={() => setChangeRoleModalOpen(false)} owner={owner} /> @@ -247,7 +256,7 @@ export default function WorkspaceAdmin({ className="transition-color flex cursor-pointer items-center justify-center gap-3 border-t border-structure-200 py-2 text-xs duration-200 hover:bg-action-100 sm:text-sm" onClick={() => { if (isInvitation(item)) setInvitationToRevoke(item); - else setChangeRoleMemberId(item.id); + else setChangeRoleMember(item); /* Delay to let react re-render the modal before opening it otherwise no animation transition */ setTimeout(() => { if (isInvitation(item)) setRevokeInvitationModalOpen(true); @@ -306,14 +315,28 @@ export default function WorkspaceAdmin({ ) )} + {(isMembersLoading || isInvitationsLoading) && ( +
+
+ +
+
+
Loading...
+
+
+
+ + Loading... + +
+
+ +
+
+ )} ); - function isInvitation( - arg: MembershipInvitationType | UserType - ): arg is MembershipInvitationType { - return (arg as MembershipInvitationType).inviteEmail !== undefined; - } } } @@ -331,6 +354,31 @@ function InviteEmailModal({ const [emailError, setEmailError] = useState(""); const [successMessage, setSuccessMessage] = useState(""); const { mutate } = useSWRConfig(); + + async function handleSendInvitation(): Promise { + if (!isEmailValid(inviteEmail)) { + setEmailError("Invalid email address."); + return; + } + const res = await fetch(`/api/w/${owner.sId}/invitations`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + inviteEmail, + }), + }); + if (!res.ok) { + window.alert("Failed to invite new member to workspace."); + } else { + setSuccessMessage( + `Invite sent to ${inviteEmail}. You can repeat the operation to invite other users.` + ); + await mutate(`/api/w/${owner.sId}/invitations`); + } + } + return ( { + setIsSending(true); + await handleSendInvitation(); + setIsSending(false); + setInviteEmail(""); + }} >
@@ -370,33 +424,6 @@ function InviteEmailModal({
); - - async function handleSendInvitation(): Promise { - if (!isEmailValid(inviteEmail)) { - setEmailError("Invalid email address."); - return; - } - setIsSending(true); - const res = await fetch(`/api/w/${owner.sId}/invitations`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - inviteEmail, - }), - }); - if (!res.ok) { - window.alert("Failed to invite new member to workspace."); - } else { - setSuccessMessage( - `Invite sent to ${inviteEmail}. You can repeat the operation to invite other users.` - ); - await mutate(`/api/w/${owner.sId}/invitations`); - } - setIsSending(false); - setInviteEmail(""); - } } function InviteSettingsModal({ @@ -411,43 +438,6 @@ function InviteSettingsModal({ const [domainUpdating, setDomainUpdating] = useState(false); const [domainInput, setDomainInput] = useState(owner.allowedDomain || ""); const [allowedDomainError, setAllowedDomainError] = useState(""); - return ( - validDomain() && handleUpdateWorkspace()} - > -
-
- Any person with a Google Workspace email on corresponding domain name - will be allowed to join the workspace. -
-
-
Whitelisted email domain
- { - setDomainInput(e); - setAllowedDomainError(""); - }} - disabled={domainUpdating} - /> -
-
-
- ); async function handleUpdateWorkspace(): Promise { setDomainUpdating(true); @@ -485,6 +475,44 @@ function InviteSettingsModal({ return valid; } + + return ( + validDomain() && handleUpdateWorkspace()} + > +
+
+ Any person with a Google Workspace email on corresponding domain name + will be allowed to join the workspace. +
+
+
Whitelisted email domain
+ { + setDomainInput(e); + setAllowedDomainError(""); + }} + disabled={domainUpdating} + /> +
+
+
+ ); } function RevokeInvitationModal({ @@ -499,6 +527,25 @@ function RevokeInvitationModal({ owner: WorkspaceType; }) { const { mutate } = useSWRConfig(); + const [isSaving, setIsSaving] = useState(false); + if (!invitation) return null; + + async function handleRevokeInvitation(invitationId: number): Promise { + const res = await fetch(`/api/w/${owner.sId}/invitations/${invitationId}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + status: "revoked", + }), + }); + if (!res.ok) { + window.alert("Failed to revoke member's invitation."); + } else { + await mutate(`/api/w/${owner.sId}/invitations`); + } + } return (
@@ -516,30 +564,18 @@ function RevokeInvitationModal({
); - - async function handleRevokeInvitation(invitationId: number): Promise { - const res = await fetch(`/api/w/${owner.sId}/invitations/${invitationId}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - status: "revoked", - }), - }); - if (!res.ok) { - window.alert("Failed to revoke member's invitation."); - } else { - await mutate(`/api/w/${owner.sId}/invitations`); - } - } } function ChangeMemberModal({ @@ -555,7 +591,31 @@ function ChangeMemberModal({ }) { const { mutate } = useSWRConfig(); const [revokeMemberModalOpen, setRevokeMemberModalOpen] = useState(false); + const [selectedRole, setSelectedRole] = useState(null); + const [isSaving, setIsSaving] = useState(false); + if (!member) return null; // Unreachable + + async function handleMemberRoleChange( + member: UserType, + role: RoleType + ): Promise { + const res = await fetch(`/api/w/${owner.sId}/members/${member.id}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + role: role === "none" ? "revoked" : role, + }), + }); + if (!res.ok) { + window.alert("Failed to update membership."); + } else { + await mutate(`/api/w/${owner.sId}/members`); + } + } + const roleTexts: { [k: string]: string } = { admin: "Admins can manage members, in addition to builders' rights.", builder: @@ -566,9 +626,20 @@ function ChangeMemberModal({ { + setIsSaving(true); + if (!selectedRole) return; // unreachable due to hasChanged + await handleMemberRoleChange(member, selectedRole); + onClose(); + setIsSaving(false); + }} + saveLabel="Update role" >
@@ -585,7 +656,7 @@ function ChangeMemberModal({
@@ -655,23 +726,4 @@ function ChangeMemberModal({ ); - async function handleMemberRoleChange( - member: UserType, - role: RoleType - ): Promise { - const res = await fetch(`/api/w/${owner.sId}/members/${member.id}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - role: role === "none" ? "revoked" : role, - }), - }); - if (!res.ok) { - window.alert("Failed to update membership."); - } else { - await mutate(`/api/w/${owner.sId}/members`); - } - } }