feat: remove members
This commit is contained in:
44
src/components/Dropdown.tsx
Normal file
44
src/components/Dropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
|
||||
6
src/server/db/migrations/0004_rainy_archangel.sql
Normal file
6
src/server/db/migrations/0004_rainy_archangel.sql
Normal 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 $$;
|
||||
1037
src/server/db/migrations/meta/0004_snapshot.json
Normal file
1037
src/server/db/migrations/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -81,7 +81,7 @@ export const getByPublicIdWithMembers = async (
|
||||
publicId,
|
||||
role,
|
||||
status,
|
||||
user (
|
||||
user!workspace_members_userId_user_id_fk (
|
||||
id,
|
||||
name,
|
||||
email
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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>(
|
||||
|
||||
57
src/views/members/components/DeleteMemberConfirmation.tsx
Normal file
57
src/views/members/components/DeleteMemberConfirmation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user