Skip to content

Commit

Permalink
Merge pull request giselles-ai#192 from giselles-ai/feat/delete-team-…
Browse files Browse the repository at this point in the history
…member

feat: Add team member deletion with validation and confirmation
  • Loading branch information
gentamura authored Dec 16, 2024
2 parents 6b512e5 + d97553d commit 52c5541
Show file tree
Hide file tree
Showing 8 changed files with 385 additions and 45 deletions.
131 changes: 113 additions & 18 deletions app/(main)/settings/team/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,22 +64,8 @@ export async function updateTeamName(teamDbId: number, formData: FormData) {

export async function getTeamMembers() {
try {
const supabaseUser = await getUser();

// Subquery: Get current user's team
// TODO: In the future, this query will be changed to retrieve from the selected team ID
const currentUserTeam = db
.select({
teamDbId: teams.dbId,
})
.from(teams)
.innerJoin(teamMemberships, eq(teams.dbId, teamMemberships.teamDbId))
.innerJoin(
supabaseUserMappings,
eq(teamMemberships.userDbId, supabaseUserMappings.userDbId),
)
.where(eq(supabaseUserMappings.supabaseUserId, supabaseUser.id))
.limit(1);
const currentTeam = await fetchCurrentTeam();

// Main query: Get team members list
const teamMembers = await db
Expand All @@ -94,7 +80,7 @@ export async function getTeamMembers() {
teamMemberships,
and(
eq(users.dbId, teamMemberships.userDbId),
eq(teamMemberships.teamDbId, currentUserTeam),
eq(teamMemberships.teamDbId, currentTeam.dbId),
),
)
.orderBy(asc(teamMemberships.id));
Expand Down Expand Up @@ -269,7 +255,6 @@ export async function updateTeamMemberRole(formData: FormData) {

return {
success: true,
isUpdatingSelf,
};
} catch (error) {
console.error("Failed to update team member role:", error);
Expand All @@ -280,7 +265,117 @@ export async function updateTeamMemberRole(formData: FormData) {
error instanceof Error
? error.message
: "Failed to update team member role",
isUpdatingSelf: false,
};
}
}

export async function deleteTeamMember(formData: FormData) {
try {
const userId = formData.get("userId") as string;
const role = formData.get("role") as string;

if (!isUserId(userId)) {
throw new Error("Invalid user ID");
}

if (!isTeamRole(role)) {
throw new Error("Invalid role");
}

// 1. Get current team and verify admin permission
const currentTeam = await fetchCurrentTeam();
const currentUserRoleResult = await getCurrentUserRole();

if (
!currentUserRoleResult.success ||
currentUserRoleResult.data !== "admin"
) {
throw new Error("Only admin users can remove team members");
}

// 2. Get current user info to check if deleting self
const supabaseUser = await getUser();
const currentUser = await db
.select({ id: users.id })
.from(users)
.innerJoin(
supabaseUserMappings,
eq(users.dbId, supabaseUserMappings.userDbId),
)
.where(eq(supabaseUserMappings.supabaseUserId, supabaseUser.id))
.limit(1);

const isDeletingSelf = currentUser[0].id === userId;

// 3. Get target user's dbId from users table
const user = await db
.select({ dbId: users.dbId })
.from(users)
.where(eq(users.id, userId))
.limit(1);

if (user.length === 0) {
throw new Error("User not found");
}

// 4. Check if user is an admin and if they are the last admin
if (role === "admin") {
const adminCount = await db
.select({
count: count(),
})
.from(teamMemberships)
.where(
and(
eq(teamMemberships.teamDbId, currentTeam.dbId),
eq(teamMemberships.role, "admin"),
),
);

if (adminCount[0].count === 1) {
throw new Error("Cannot remove the last admin from the team");
}
}

// 5. If deleting self, check if user has other teams
if (isDeletingSelf) {
const teamCount = await db
.select({
count: count(),
})
.from(teamMemberships)
.where(
and(
eq(teamMemberships.userDbId, user[0].dbId),
ne(teamMemberships.teamDbId, currentTeam.dbId), // Exclude current team
),
);

if (teamCount[0].count === 0) {
throw new Error("Cannot leave the team when it's your only team");
}
}

// 6. Delete team membership
await db
.delete(teamMemberships)
.where(
and(
eq(teamMemberships.teamDbId, currentTeam.dbId),
eq(teamMemberships.userDbId, user[0].dbId),
),
);

revalidatePath("/settings/team");

return { success: true };
} catch (error) {
console.error("Failed to delete team member:", error);

return {
success: false,
error:
error instanceof Error ? error.message : "Failed to delete team member",
};
}
}
Expand Down
98 changes: 80 additions & 18 deletions app/(main)/settings/team/team-members-list-item.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
"use client";

import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Select,
Expand All @@ -9,32 +20,31 @@ import {
SelectValue,
} from "@/components/ui/select";
import type { TeamRole } from "@/drizzle";
import { Check, Pencil, X } from "lucide-react";
import { Check, Pencil, Trash2, X } from "lucide-react";
import { useState } from "react";
import { updateTeamMemberRole } from "./actions";
import { deleteTeamMember, updateTeamMemberRole } from "./actions";

type TeamMemberListItemProps = {
userId: string;
displayName: string | null;
email: string | null;
role: TeamRole;
currentUserRoleState: [TeamRole, (role: TeamRole) => void];
currentUserRole: TeamRole;
};

export function TeamMemberListItem({
userId,
displayName,
email,
role: initialRole,
currentUserRoleState,
currentUserRole,
}: TeamMemberListItemProps) {
const [isEditingRole, setIsEditingRole] = useState(false);
const [role, setRole] = useState<TeamRole>(initialRole);
const [tempRole, setTempRole] = useState<TeamRole>(role);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string>("");

const [currentUserRole, setCurrentUserRole] = currentUserRoleState;
const canEditRole = currentUserRole === "admin";
const handleRoleChange = (value: TeamRole) => {
setTempRole(value);
Expand All @@ -50,16 +60,11 @@ export function TeamMemberListItem({
formData.append("userId", userId);
formData.append("role", tempRole);

const { success, isUpdatingSelf, error } =
await updateTeamMemberRole(formData);
const { success, error } = await updateTeamMemberRole(formData);

if (success) {
setIsEditingRole(false);
setRole(tempRole);

if (isUpdatingSelf) {
setCurrentUserRole(tempRole);
}
} else {
const errorMsg = error || "Failed to update role";
setError(errorMsg);
Expand All @@ -81,12 +86,39 @@ export function TeamMemberListItem({
setError("");
};

const handleDelete = async () => {
setError("");

try {
setIsLoading(true);

const formData = new FormData();
formData.append("userId", userId);
formData.append("role", role);

const { success, error } = await deleteTeamMember(formData);

if (!success) {
const errorMsg = error || "Failed to delete member";
setError(errorMsg);
console.error(errorMsg);
}
} catch (error) {
if (error instanceof Error) {
setError(error.message);
}
console.error("Error:", error);
} finally {
setIsLoading(false);
}
};

return (
<div className="grid grid-cols-[1fr_1fr_200px] gap-4 p-4 items-center text-zinc-200">
<div className="text-zinc-400">{displayName || "No display name"}</div>
<div className="text-zinc-400">{email || "No email"}</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<div className="flex items-center gap-3">
{isEditingRole ? (
<>
<Select
Expand Down Expand Up @@ -121,12 +153,42 @@ export function TeamMemberListItem({
<>
<span className="text-zinc-400 capitalize w-[100px]">{role}</span>
{canEditRole && (
<Button
className="shrink-0 h-8 w-8 rounded-full p-0"
onClick={() => setIsEditingRole(true)}
>
<Pencil className="h-4 w-4" />
</Button>
<>
<Button
className="shrink-0 h-8 w-8 rounded-full p-0"
onClick={() => setIsEditingRole(true)}
>
<Pencil className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
className="shrink-0 h-8 w-8 rounded-full p-0"
disabled={isLoading}
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove team member</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove {displayName || email}{" "}
from the team? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 dark:text-white"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
</>
)}
Expand Down
11 changes: 4 additions & 7 deletions app/(main)/settings/team/team-members-list.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
"use client";

import type { TeamRole } from "@/drizzle";
import { useState } from "react";
import { TeamMemberListItem } from "./team-members-list-item";

type TeamMembersListProps = {
teamDbId: number;
members: {
userId: string;
displayName: string | null;
Expand All @@ -15,11 +13,10 @@ type TeamMembersListProps = {
};

export function TeamMembersList({
teamDbId,
members,
currentUserRole,
}: TeamMembersListProps) {
const currentUserRoleState = useState(currentUserRole);

return (
<div className="font-avenir rounded-[16px]">
<div className="grid grid-cols-[1fr_1fr_200px] gap-4 border-b border-zinc-800 bg-zinc-900/50 p-4 font-medium text-zinc-200">
Expand All @@ -30,12 +27,12 @@ export function TeamMembersList({
<div className="divide-y divide-zinc-800">
{members.map((member) => (
<TeamMemberListItem
key={member.userId}
key={`${teamDbId}-${member.userId}`}
userId={member.userId}
displayName={member.displayName}
email={member.email}
role={member.role}
currentUserRoleState={currentUserRoleState}
currentUserRole={currentUserRole}
/>
))}
</div>
Expand Down
6 changes: 5 additions & 1 deletion app/(main)/settings/team/team-members.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ export async function TeamMembers() {
return (
<Card title="Team members">
{isProPlan(team) && currentUserRole === "admin" && <TeamMembersForm />}
<TeamMembersList members={members} currentUserRole={currentUserRole} />
<TeamMembersList
teamDbId={team.dbId}
members={members}
currentUserRole={currentUserRole}
/>
</Card>
);
}
Binary file modified bun.lockb
Binary file not shown.
Loading

0 comments on commit 52c5541

Please sign in to comment.