Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Settings #12

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ interface User {
id: string;
name: string | null;
image: string | null;
role: string;
}

export const getOrganizationUsersAction = authActionClient
Expand All @@ -31,6 +32,7 @@ export const getOrganizationUsersAction = authActionClient
organizationId: ctx.user.organizationId,
},
select: {
role: true,
user: {
select: {
id: true,
Expand All @@ -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) {
Expand Down
72 changes: 72 additions & 0 deletions apps/app/src/actions/organization/team/change-user-role-action.ts
Original file line number Diff line number Diff line change
@@ -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,
};
});
67 changes: 67 additions & 0 deletions apps/app/src/actions/organization/team/remove-member-action.ts
Original file line number Diff line number Diff line change
@@ -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"));
}
});

12 changes: 12 additions & 0 deletions apps/app/src/actions/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
RiskCategory,
RiskStatus,
RiskTaskStatus,
Role,
VendorCategory,
VendorStatus,
} from "@bubba/db";
Expand Down Expand Up @@ -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(),
});
6 changes: 4 additions & 2 deletions apps/app/src/components/settings/team/team-members.tsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -13,7 +13,9 @@ export function TeamMembers() {
</TabsTrigger>
</TabsList>

<TabsContent value="members">Test</TabsContent>
<TabsContent value="members">
<MembersTable />
</TabsContent>

<TabsContent value="pending">Test 2</TabsContent>
</Tabs>
Expand Down
185 changes: 185 additions & 0 deletions apps/app/src/components/tables/members/columns.tsx
Original file line number Diff line number Diff line change
@@ -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<MemberType>[] {
return [
{
id: "name",
accessorKey: "name",
header: "Name",
cell: ({ row }) => (
<div>
<div className="flex items-center space-x-4">
<Avatar className="rounded-full w-8 h-8">
<AvatarImageNext
src={row.original.user?.image}
alt={row.original.user?.name}
width={32}
height={32}
/>
<AvatarFallback>
<span className="text-xs">
{row.original.user.name?.charAt(0)?.toUpperCase()}
</span>
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="font-medium text-sm">
{row.original.user.name}
</span>
<span className="text-sm text-muted">
{row.original.user.email}
</span>
</div>
</div>
</div>
),
},
{
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 (
<div className="flex justify-end">
<div className="flex space-x-2 items-center">
<Select
value={row.original.role}
onValueChange={(role) => {
changeUserRole.execute({
userId: row.original.user.id,
organizationId: row.original.user.organizationId,
role: role as Role,
});
}}
>
<SelectTrigger>
<SelectValue placeholder={t(`roles.${row.original.role}`)} />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">{t("roles.admin")}</SelectItem>
<SelectItem value="member">{t("roles.member")}</SelectItem>
<SelectItem value="auditor">{t("roles.auditor")}</SelectItem>
</SelectContent>
</Select>

<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<AlertDialog>
<DropdownMenuItem
className="text-destructive"
asDialogTrigger
>
<AlertDialogTrigger>
{t("settings.members.remove_member")}
</AlertDialogTrigger>
</DropdownMenuItem>

<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.members.remove_member")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.members.remove_member_description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("settings.members.cancel_button")}
</AlertDialogCancel>
<AlertDialogAction
disabled={removeMember.status === "executing"}
onClick={() =>
removeMember.execute({
userId: row.original.user.id,
organizationId: row.original.user.organizationId,
})
}
>
{removeMember.status === "executing" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
t("settings.members.confirm_button")
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
},
},
];
}
Loading