feat: remove members

This commit is contained in:
Henry
2024-10-29 13:17:44 +00:00
parent f9997a0645
commit 4759d460ac
15 changed files with 1322 additions and 51 deletions

View File

@@ -0,0 +1,44 @@
import { Fragment } from "react";
import { Menu, Transition } from "@headlessui/react";
export default function Dropdown({
items,
children,
}: {
items: { label: string; action: () => void }[];
children: React.ReactNode;
}) {
return (
<Menu as="div" className="relative inline-block text-left">
<div>
<Menu.Button className="flex h-8 w-8 items-center justify-center rounded-[5px] hover:bg-light-200 dark:hover:bg-dark-200">
{children}
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 z-30 mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
<div className="flex">
{items.map((item) => (
<Menu.Item key={item.label}>
<button
onClick={item.action}
className="m-1 w-full rounded-[5px] px-3 py-2 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
>
{item.label}
</button>
</Menu.Item>
))}
</div>
</Menu.Items>
</Transition>
</Menu>
);
}

View File

@@ -1,5 +1,6 @@
import { WorkspaceProvider } from "~/providers/workspace";
import Dashboard from "~/components/dashboard";
import Popup from "~/components/Popup";
import MembersView from "~/views/members";
export default function MembersPage() {
@@ -8,6 +9,7 @@ export default function MembersPage() {
<Dashboard>
<MembersView />
</Dashboard>
<Popup />
</WorkspaceProvider>
);
}

View File

@@ -2,10 +2,15 @@ import { createContext, useContext, useState } from "react";
type ModalContextType = {
isOpen: boolean;
openModal: (contentType: string, entityId?: string) => void;
openModal: (
contentType: string,
entityId?: string,
entityLabel?: string,
) => void;
closeModal: () => void;
modalContentType: string;
entityId: string;
entityLabel: string;
};
interface Props {
@@ -17,12 +22,18 @@ const ModalContext = createContext<ModalContextType | undefined>(undefined);
export const ModalProvider: React.FC<Props> = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
const [entityId, setEntityId] = useState("");
const [entityLabel, setEntityLabel] = useState("");
const [modalContentType, setModalContentType] = useState("");
const openModal = (contentType: string, entityId?: string) => {
const openModal = (
contentType: string,
entityId?: string,
entityLabel?: string,
) => {
setIsOpen(true);
setModalContentType(contentType);
if (entityId) setEntityId(entityId);
if (entityLabel) setEntityLabel(entityLabel);
};
const closeModal = () => {
@@ -31,7 +42,14 @@ export const ModalProvider: React.FC<Props> = ({ children }) => {
return (
<ModalContext.Provider
value={{ isOpen, openModal, closeModal, modalContentType, entityId }}
value={{
isOpen,
openModal,
closeModal,
modalContentType,
entityId,
entityLabel,
}}
>
{children}
</ModalContext.Provider>

View File

@@ -131,4 +131,38 @@ export const memberRouter = createTRPCRouter({
return invite;
}),
delete: protectedProcedure
.input(
z.object({
memberPublicId: z.string().min(12),
}),
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const member = await memberRepo.getByPublicId(
ctx.db,
input.memberPublicId,
);
if (!member)
throw new TRPCError({
message: `Member with public ID ${input.memberPublicId} not found`,
code: "NOT_FOUND",
});
const deletedMember = await memberRepo.softDelete(ctx.db, {
memberId: member.id,
deletedAt: new Date().toISOString(),
deletedBy: userId,
});
return deletedMember;
}),
});

View File

@@ -0,0 +1,6 @@
ALTER TABLE "workspace_members" ADD COLUMN "deletedBy" uuid;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "user"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,13 @@
"when": 1728246215706,
"tag": "0003_naive_secret_warriors",
"breakpoints": true
},
{
"idx": 4,
"version": "5",
"when": 1730205607613,
"tag": "0004_rainy_archangel",
"breakpoints": true
}
]
}

View File

@@ -33,7 +33,7 @@ export const getByPublicId = async (
publicId,
members:workspace_members (
publicId,
user (
user!workspace_members_userId_user_id_fk (
name
)
)
@@ -61,7 +61,7 @@ export const getByPublicId = async (
),
members:workspace_members${filters.members.length > 0 ? "!inner" : ""} (
publicId,
user (
user!workspace_members_userId_user_id_fk (
name
)
)
@@ -72,7 +72,9 @@ export const getByPublicId = async (
.eq("publicId", boardPublicId)
.is("deletedAt", null)
.is("lists.deletedAt", null)
.is("lists.cards.deletedAt", null);
.is("lists.cards.deletedAt", null)
.is("workspace.members.deletedAt", null)
.is("lists.cards.members.deletedAt", null);
if (filters.labels.length > 0) {
query = query.in("lists.cards.labels.publicId", filters.labels);

View File

@@ -209,7 +209,7 @@ export const getWithListAndMembersByPublicId = async (
publicId,
members:workspace_members (
publicId,
user (
user!workspace_members_userId_user_id_fk (
id,
name
)
@@ -219,7 +219,7 @@ export const getWithListAndMembersByPublicId = async (
),
members:workspace_members (
publicId,
user (
user!workspace_members_userId_user_id_fk (
id,
name
)
@@ -229,6 +229,8 @@ export const getWithListAndMembersByPublicId = async (
.eq("publicId", cardPublicId)
.is("deletedAt", null)
.is("list.board.lists.deletedAt", null)
.is("list.board.workspace.members.deletedAt", null)
.is("members.deletedAt", null)
.limit(1)
.single();

View File

@@ -54,3 +54,20 @@ export const acceptInvite = async (
return data;
};
export const softDelete = async (
db: SupabaseClient<Database>,
args: {
memberId: number;
deletedAt: string;
deletedBy: string;
},
) => {
const result = await db
.from("workspace_members")
.update({ deletedAt: args.deletedAt, deletedBy: args.deletedBy })
.eq("id", args.memberId)
.is("deletedAt", null);
return result;
};

View File

@@ -81,7 +81,7 @@ export const getByPublicIdWithMembers = async (
publicId,
role,
status,
user (
user!workspace_members_userId_user_id_fk (
id,
name,
email

View File

@@ -298,6 +298,7 @@ export const workspaceMembers = pgTable("workspace_members", {
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id),
role: memberRoleEnum("role").notNull(),
status: memberStatusEnum("status").default("invited").notNull(),
});
@@ -309,6 +310,10 @@ export const usersToWorkspacesRelations = relations(
fields: [workspaceMembers.createdBy],
references: [users.id],
}),
deletedBy: one(users, {
fields: [workspaceMembers.deletedBy],
references: [users.id],
}),
user: one(users, {
fields: [workspaceMembers.userId],
references: [users.id],

View File

@@ -6,7 +6,7 @@ import {
import { RequestCookies } from "@edge-runtime/cookies";
import { type Database } from "~/types/database.types";
import { type NextRequest, NextResponse } from "next/server";
import { type NextRequest, type NextResponse } from "next/server";
export function createNextClient(req: NextRequest, res: NextResponse) {
const supabase = createServerClient<Database>(

View File

@@ -0,0 +1,57 @@
import { api } from "~/utils/api";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import Button from "~/components/Button";
export function DeleteMemberConfirmation() {
const utils = api.useUtils();
const { closeModal, entityLabel, entityId } = useModal();
const { showPopup } = usePopup();
const deleteMember = api.member.delete.useMutation({
onSuccess: async () => {
closeModal();
try {
await utils.workspace.byId.refetch();
} catch (e) {
console.error(e);
}
},
onError: () => {
showPopup({
header: "Unable to remove member",
message: "Please try again later, or contact customer support.",
});
closeModal();
},
});
const handleDeleteMember = () => {
if (entityId)
deleteMember.mutate({
memberPublicId: entityId,
});
};
return (
<div className="p-5">
<div className="flex w-full flex-col justify-between pb-4">
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
{`Are you sure want to remove ${entityLabel}?`}
</h2>
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
{"They won't be able to access this workspace."}
</p>
</div>
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
<Button onClick={() => closeModal()} variant="secondary">
Cancel
</Button>
<Button onClick={handleDeleteMember} isLoading={deleteMember.isPending}>
Remove
</Button>
</div>
</div>
);
}

View File

@@ -1,10 +1,13 @@
import { HiOutlinePlusSmall } from "react-icons/hi2";
import { HiOutlinePlusSmall, HiEllipsisHorizontal } from "react-icons/hi2";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { InviteMemberForm } from "./components/InviteMemberForm";
import { DeleteMemberConfirmation } from "./components/DeleteMemberConfirmation";
import Dropdown from "~/components/Dropdown";
import { api } from "~/utils/api";
import { getInitialsFromName, inferInitialsFromEmail } from "~/utils/helpers";
@@ -41,71 +44,107 @@ export default function MembersPage() {
</div>
<div className="mt-8 flow-root">
<div className="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="-mx-4 -my-2 overflow-x-visible sm:-mx-6 lg:-mx-8">
<div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
<div className="h-full shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
<table className="min-w-full divide-y divide-light-600 dark:divide-dark-600">
<thead className="bg-light-300 dark:bg-dark-200">
<tr>
<thead className="rounded-t-lg bg-light-300 dark:bg-dark-200">
<tr className="">
<th
scope="col"
className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-light-900 dark:text-dark-900 sm:pl-6"
className="rounded-tl-lg py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-light-900 dark:text-dark-900 sm:pl-6"
>
User
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
className="rounded-tr-lg px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
>
Role
</th>
</tr>
</thead>
<tbody className="divide-y divide-light-600 bg-light-50 dark:divide-dark-600 dark:bg-dark-100">
{data?.members.map((member) => {
{data?.members.map((member, index) => {
const initials = member.user?.name
? getInitialsFromName(member.user.name)
: inferInitialsFromEmail(member.user?.email ?? "");
return (
<tr key={member.publicId}>
<td>
<div className="flex items-center p-4">
<div className="flex-shrink-0">
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-light-1000 dark:bg-dark-400">
<span className="text-sm font-medium leading-none text-white">
{initials}
<>
<tr key={member.publicId} className="rounded-b-lg">
<td
className={
index === data.members.length - 1
? "rounded-bl-lg"
: ""
}
>
<div className="flex items-center p-4">
<div className="flex-shrink-0">
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-light-1000 dark:bg-dark-400">
<span className="text-sm font-medium leading-none text-white">
{initials}
</span>
</span>
</span>
</div>
<div className="ml-2 min-w-0 flex-1">
<div>
<div className="flex items-center">
<p className="mr-2 text-sm font-medium text-neutral-900 dark:text-dark-1000">
{member.user?.name}
</div>
<div className="ml-2 min-w-0 flex-1">
<div>
<div className="flex items-center">
<p className="mr-2 text-sm font-medium text-neutral-900 dark:text-dark-1000">
{member.user?.name}
</p>
</div>
<p className="truncate text-sm text-dark-900">
{member.user?.email}
</p>
</div>
<p className="truncate text-sm text-dark-900">
{member.user?.email}
</p>
</div>
</div>
</div>
</td>
<td>
<div className="px-3">
<span className="inline-flex items-center rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[11px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20">
{member.role.charAt(0).toUpperCase() +
member.role.slice(1)}
</span>
{member.status === "invited" && (
<span className="ml-2 inline-flex items-center rounded-md bg-gray-500/10 px-1.5 py-0.5 text-[11px] font-medium text-gray-400 ring-1 ring-inset ring-gray-500/20">
Pending
</span>
)}
</div>
</td>
</tr>
</td>
<td
className={
index === data.members.length - 1
? "rounded-br-lg"
: ""
}
>
<div className="flex items-center justify-between px-3">
<div>
<span className="inline-flex items-center rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[11px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20">
{member.role.charAt(0).toUpperCase() +
member.role.slice(1)}
</span>
{member.status === "invited" && (
<span className="ml-2 inline-flex items-center rounded-md bg-gray-500/10 px-1.5 py-0.5 text-[11px] font-medium text-gray-400 ring-1 ring-inset ring-gray-500/20">
Pending
</span>
)}
</div>
<div className="relative">
<Dropdown
items={[
{
label: "Remove member",
action: () =>
openModal(
"REMOVE_MEMBER",
member.publicId,
member.user?.email ?? "",
),
},
]}
>
<HiEllipsisHorizontal
size={25}
className="text-light-900 dark:text-dark-900"
/>
</Dropdown>
</div>
</div>
</td>
</tr>
</>
);
})}
</tbody>
@@ -118,6 +157,7 @@ export default function MembersPage() {
<Modal>
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
{modalContentType === "INVITE_MEMBER" && <InviteMemberForm />}
{modalContentType === "REMOVE_MEMBER" && <DeleteMemberConfirmation />}
</Modal>
</div>
);