diff --git a/apps/app/src/actions/organization/get-organization-users-action.ts b/apps/app/src/actions/organization/get-organization-users-action.ts index 451d018..9bd2c31 100644 --- a/apps/app/src/actions/organization/get-organization-users-action.ts +++ b/apps/app/src/actions/organization/get-organization-users-action.ts @@ -7,6 +7,7 @@ interface User { id: string; name: string | null; image: string | null; + role: string; } export const getOrganizationUsersAction = authActionClient @@ -31,6 +32,7 @@ export const getOrganizationUsersAction = authActionClient organizationId: ctx.user.organizationId, }, select: { + role: true, user: { select: { id: true, @@ -48,10 +50,11 @@ export const getOrganizationUsersAction = authActionClient return { success: true, - data: users.map((user) => ({ - id: user.user.id, - name: user.user.name || "", - image: user.user.image || "", + data: users.map((member) => ({ + id: member.user.id, + name: member.user.name || "", + image: member.user.image || "", + role: member.role, })), }; } catch (error) { diff --git a/apps/app/src/actions/organization/team/change-user-role-action.ts b/apps/app/src/actions/organization/team/change-user-role-action.ts new file mode 100644 index 0000000..3488d91 --- /dev/null +++ b/apps/app/src/actions/organization/team/change-user-role-action.ts @@ -0,0 +1,72 @@ +// change-user-role-action.ts + +"use server"; + +import { Role, db } from "@bubba/db"; +import { revalidatePath, revalidateTag } from "next/cache"; +import { authActionClient } from "../../safe-action"; +import { changeUserRoleSchema } from "../../schema"; + +export const changeUserRoleAction = authActionClient + .schema(changeUserRoleSchema) + .metadata({ + name: "change-user-role", + track: { + event: "change-user-role", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { userId, organizationId, role } = parsedInput; + + const currentUserRole = await db.organizationMember.findFirst({ + where: { + userId: ctx.user.id, + organizationId, + }, + }); + + if (!currentUserRole || currentUserRole.role !== Role.admin) { + throw new Error("You are not authorized to change user roles"); + } + + if (role !== Role.admin) { + const adminCount = await db.organizationMember.count({ + where: { + organizationId, + role: Role.admin, + }, + }); + + const isTargetUserAdmin = await db.organizationMember.findFirst({ + where: { + userId, + organizationId, + role: Role.admin, + }, + }); + + if (adminCount === 1 && isTargetUserAdmin) { + throw new Error("Cannot change role - organization must have at least one admin"); + } + } + + await db.organizationMember.update({ + where: { + userId_organizationId: { + userId, + organizationId, + }, + }, + data: { + role, + }, + }); + + revalidateTag("organization-members"); + revalidatePath("/settings/members"); + + return { + success: true, + }; + }); diff --git a/apps/app/src/actions/organization/team/remove-member-action.ts b/apps/app/src/actions/organization/team/remove-member-action.ts new file mode 100644 index 0000000..76e3288 --- /dev/null +++ b/apps/app/src/actions/organization/team/remove-member-action.ts @@ -0,0 +1,67 @@ +// remove-member-action.ts + +"use server"; + +import { getI18n } from "@/locales/server"; +import { Role, db } from "@bubba/db"; +import { revalidatePath, revalidateTag } from "next/cache"; +import { authActionClient } from "../../safe-action"; +import { removeMemberSchema } from "../../schema"; + +export const removeMemberAction = authActionClient + .schema(removeMemberSchema) + .metadata({ + name: "remove-member", + track: { + event: "remove-member", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const t = await getI18n(); + + try { + const { userId, organizationId } = parsedInput; + + // Check if current user is admin + const currentUserRole = await db.organizationMember.findFirst({ + where: { + userId: ctx.user.id, + organizationId, + }, + }); + + if (!currentUserRole || currentUserRole.role !== Role.admin) { + throw new Error(t("settings.members.remove_member_error")); + } + + const totalMembers = await db.organizationMember.count({ + where: { + organizationId, + }, + }); + + if (userId === ctx.user.id && totalMembers === 1) { + throw new Error(t("settings.members.last_member_error")); + } + + await db.organizationMember.delete({ + where: { + userId_organizationId: { + userId, + organizationId, + }, + }, + }); + + revalidateTag("organization-members"); + revalidatePath("/settings/members"); + + return { + success: true, + }; + } catch (error) { + throw new Error(t("settings.members.remove_member_error")); + } + }); + diff --git a/apps/app/src/actions/schema.ts b/apps/app/src/actions/schema.ts index 6f069b6..1aae5bf 100644 --- a/apps/app/src/actions/schema.ts +++ b/apps/app/src/actions/schema.ts @@ -3,6 +3,7 @@ import { RiskCategory, RiskStatus, RiskTaskStatus, + Role, VendorCategory, VendorStatus, } from "@bubba/db"; @@ -258,3 +259,14 @@ export const updatePolicySchema = z.object({ export const assistantSettingsSchema = z.object({ enabled: z.boolean().optional(), }); + +export const changeUserRoleSchema = z.object({ + userId: z.string(), + organizationId: z.string(), + role: z.nativeEnum(Role), +}); + +export const removeMemberSchema = z.object({ + userId: z.string(), + organizationId: z.string(), +}); diff --git a/apps/app/src/components/settings/team/team-members.tsx b/apps/app/src/components/settings/team/team-members.tsx index d171e5c..f6fa268 100644 --- a/apps/app/src/components/settings/team/team-members.tsx +++ b/apps/app/src/components/settings/team/team-members.tsx @@ -1,5 +1,5 @@ +import { MembersTable } from "@/components/tables/members"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@bubba/ui/tabs"; -import { Suspense } from "react"; export function TeamMembers() { return ( @@ -13,7 +13,9 @@ export function TeamMembers() { - Test + + + Test 2 diff --git a/apps/app/src/components/tables/members/columns.tsx b/apps/app/src/components/tables/members/columns.tsx new file mode 100644 index 0000000..2015e6f --- /dev/null +++ b/apps/app/src/components/tables/members/columns.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { changeUserRoleAction } from "@/actions/organization/team/change-user-role-action"; +import { removeMemberAction } from "@/actions/organization/team/remove-member-action"; +import { useI18n } from "@/locales/client"; +import type { Role } from "@bubba/db"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@bubba/ui/alert-dialog"; +import { Avatar, AvatarFallback, AvatarImageNext } from "@bubba/ui/avatar"; +import { Button } from "@bubba/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@bubba/ui/dropdown-menu"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@bubba/ui/select"; +import type { ColumnDef } from "@tanstack/react-table"; +import { Loader2, MoreHorizontal, Trash2Icon } from "lucide-react"; +import { useAction } from "next-safe-action/hooks"; +import { toast } from "sonner"; + +export type MemberType = { + user: { + id: string; + name: string; + image: string; + email: string; + organizationId: string; + }; + role: Role; +}; + +export function columns(): ColumnDef[] { + return [ + { + id: "name", + accessorKey: "name", + header: "Name", + cell: ({ row }) => ( +
+
+ + + + + {row.original.user.name?.charAt(0)?.toUpperCase()} + + + +
+ + {row.original.user.name} + + + {row.original.user.email} + +
+
+
+ ), + }, + { + id: "actions", + cell: ({ row, table }) => { + const t = useI18n(); + + const changeUserRole = useAction(changeUserRoleAction, { + onSuccess: () => { + toast.success(t("roles.success_changing_user_role")); + }, + onError: () => { + toast.error(t("roles.error_changing_user_role")); + }, + }); + + const removeMember = useAction(removeMemberAction, { + onSuccess: () => { + toast.success(t("settings.members.remove_member_success")); + }, + onError: () => { + toast.error(t("settings.members.remove_member_error")); + }, + }); + + return ( +
+
+ + + + + + + + + + + {t("settings.members.remove_member")} + + + + + + + {t("settings.members.remove_member")} + + + {t("settings.members.remove_member_description")} + + + + + {t("settings.members.cancel_button")} + + + removeMember.execute({ + userId: row.original.user.id, + organizationId: row.original.user.organizationId, + }) + } + > + {removeMember.status === "executing" ? ( + + ) : ( + t("settings.members.confirm_button") + )} + + + + + + +
+
+ ); + }, + }, + ]; +} diff --git a/apps/app/src/components/tables/members/data-table.tsx b/apps/app/src/components/tables/members/data-table.tsx new file mode 100644 index 0000000..8b104a3 --- /dev/null +++ b/apps/app/src/components/tables/members/data-table.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { Loading } from "@/components/tables/risk-tasks/loading"; +import { useI18n } from "@/locales/client"; +import { Table, TableBody, TableCell, TableRow } from "@bubba/ui/table"; +import { + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; +import type { User } from "next-auth"; +import { Suspense } from "react"; +import { cn } from "../../../../../../packages/ui/src/utils"; +import { type MemberType, columns as getColumns } from "./columns"; + +interface DataTableProps { + data: MemberType[]; + currentUser: User; +} + +export function DataTable({ data, currentUser }: DataTableProps) { + const columns = getColumns(); + const t = useI18n(); + + const table = useReactTable({ + data: data, + columns, + getCoreRowModel: getCoreRowModel(), + meta: { + currentUser, + }, + }); + + return ( + }> +
+ + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getAllCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + {t("roles.no_members")} + + + )} + +
+
+
+ ); +} diff --git a/apps/app/src/components/tables/members/index.tsx b/apps/app/src/components/tables/members/index.tsx index c1fea0e..31a10f3 100644 --- a/apps/app/src/components/tables/members/index.tsx +++ b/apps/app/src/components/tables/members/index.tsx @@ -1,11 +1,34 @@ -import { getOrganizationUsersAction } from "@/actions/organization/get-organization-users-action"; import { auth } from "@/auth"; import { db } from "@bubba/db"; +import { redirect } from "next/navigation"; +import type { MemberType } from "./columns"; +import { DataTable } from "./data-table"; export async function MembersTable() { const session = await auth(); - const members = await getOrganizationUsersAction(); + const members = await db.organizationMember.findMany({ + where: { + organizationId: session?.user.organizationId, + }, + include: { + user: { + select: { + id: true, + name: true, + image: true, + email: true, + organizationId: true, + }, + }, + }, + }); - return
MembersTable
; + if (!session?.user) { + redirect("/"); + } + + return ( + + ); } diff --git a/apps/app/src/locales/en.ts b/apps/app/src/locales/en.ts index 6536895..40d0cf5 100644 --- a/apps/app/src/locales/en.ts +++ b/apps/app/src/locales/en.ts @@ -137,6 +137,14 @@ export default { }, }, }, + roles: { + admin: "Admin", + member: "Member", + auditor: "Auditor", + error_changing_user_role: "Failed to change user role", + success_changing_user_role: "User role changed successfully", + no_members: "No members found", + }, header: { discord: { button: "Join us on Discord", @@ -473,6 +481,14 @@ export default { }, members: { title: "Members", + remove_member: "Remove Member", + remove_member_success: "Member removed successfully", + remove_member_error: "Failed to remove member", + last_member_error: "Cannot remove yourself as the last member of the organization", + last_admin_error: "Cannot change role - organization must have at least one admin", + remove_member_description: "You are about to remove the following team member, are you sure you want to continue?", + cancel_button: "Cancel", + confirm_button: "Confirm", }, billing: { title: "Billing", diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 4d2919a..f59fcfa 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -695,10 +695,9 @@ model RiskAttachment { } enum MembershipRole { - owner admin member - viewer + auditor } model OrganizationMember {