From 914f9ec1060ed4fd136e32e1be555bd6df7d0ac5 Mon Sep 17 00:00:00 2001 From: Eric McDaniel Date: Mon, 15 Sep 2025 16:19:55 -0400 Subject: [PATCH 1/6] feat: add wip member editing modal --- .../members/getMemberTableColumns.tsx | 25 ++- core/app/c/[communitySlug]/members/page.tsx | 1 + .../Memberships/EditMemberDialog.tsx | 38 ++++ .../components/Memberships/MemberEditForm.tsx | 165 ++++++++++++++++++ .../Memberships/memberInviteFormSchema.ts | 5 + core/app/components/Memberships/types.ts | 23 +++ 6 files changed, 254 insertions(+), 3 deletions(-) create mode 100644 core/app/components/Memberships/EditMemberDialog.tsx create mode 100644 core/app/components/Memberships/MemberEditForm.tsx diff --git a/core/app/c/[communitySlug]/members/getMemberTableColumns.tsx b/core/app/c/[communitySlug]/members/getMemberTableColumns.tsx index 30d517504..52c380fcb 100644 --- a/core/app/c/[communitySlug]/members/getMemberTableColumns.tsx +++ b/core/app/c/[communitySlug]/members/getMemberTableColumns.tsx @@ -2,7 +2,13 @@ import type { ColumnDef } from "@tanstack/react-table"; -import type { FormsId, UsersId } from "db/public"; +import type { + CommunityMembershipsId, + FormsId, + PubMembershipsId, + StageMembershipsId, + UsersId, +} from "db/public"; import { MemberRole, MembershipType } from "db/public"; import { Avatar, AvatarFallback, AvatarImage } from "ui/avatar"; import { Badge } from "ui/badge"; @@ -16,14 +22,15 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "ui/dropdown-menu"; -import { Info, MoreVertical } from "ui/icon"; -import { Tooltip, TooltipContent, TooltipTrigger } from "ui/tooltip"; +import { MoreVertical } from "ui/icon"; +import { EditMemberDialog } from "~/app/components/Memberships/EditMemberDialog"; import { descriptions } from "~/app/components/Memberships/MemberInviteForm"; import { RemoveMemberButton } from "./RemoveMemberButton"; export type TableMember = { id: UsersId; + memberId: CommunityMembershipsId | StageMembershipsId | PubMembershipsId; avatar: string | null; email: string; firstName: string; @@ -166,6 +173,18 @@ export const getMemberTableColumns = () =>
+
+ form.id) ?? [], + }} + updateMember={async () => {}} + membershipType={MembershipType.community} + availableForms={[]} + /> +
); diff --git a/core/app/c/[communitySlug]/members/page.tsx b/core/app/c/[communitySlug]/members/page.tsx index 2eeddfec8..204790594 100644 --- a/core/app/c/[communitySlug]/members/page.tsx +++ b/core/app/c/[communitySlug]/members/page.tsx @@ -69,6 +69,7 @@ export default async function Page(props: { const { id, createdAt, user, role } = member; return { id: user.id, + memberId: id, avatar: user.avatar, firstName: user.firstName, lastName: user.lastName, diff --git a/core/app/components/Memberships/EditMemberDialog.tsx b/core/app/components/Memberships/EditMemberDialog.tsx new file mode 100644 index 000000000..69efbdb3e --- /dev/null +++ b/core/app/components/Memberships/EditMemberDialog.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useState } from "react"; + +import { Button } from "ui/button"; +import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "ui/dialog"; +import { UserPlus } from "ui/icon"; +import { Tooltip, TooltipContent, TooltipTrigger } from "ui/tooltip"; +import { cn } from "utils"; + +import type { MemberEditDialogProps } from "./types"; +import { MemberEditForm } from "./MemberEditForm"; + +export const EditMemberDialog = (props: MemberEditDialogProps & { className?: string }) => { + const [open, setOpen] = useState(false); + return ( + + + Edit a user in your community + + + + + + + + + Edit Member + setOpen(false)} {...props} /> + + + ); +}; diff --git a/core/app/components/Memberships/MemberEditForm.tsx b/core/app/components/Memberships/MemberEditForm.tsx new file mode 100644 index 000000000..3bb9a4c7f --- /dev/null +++ b/core/app/components/Memberships/MemberEditForm.tsx @@ -0,0 +1,165 @@ +"use client"; + +import type { z } from "zod"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; + +import { MemberRole, MembershipType } from "db/public"; +import { Button } from "ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "ui/form"; +import { Loader2, UserPlus } from "ui/icon"; +import { MultiSelect } from "ui/multi-select"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "ui/select"; +import { toast } from "ui/use-toast"; + +import type { MemberEditDialogProps } from "./types"; +import { didSucceed, useServerAction } from "~/lib/serverActions"; +import { memberEditFormSchema } from "./memberInviteFormSchema"; + +export const descriptions: Record = { + [MembershipType.pub]: + "Select the forms via which this member can edit and view this Pub. If no form is selected, they will only be able to view the Pub, and will only see fields added to the default Pub form for this type.", + [MembershipType.stage]: + "Select the forms via which this member can edit and view Pubs in this stage. If no form is selected, they will only be able to view Pubs in this stage, and will only see fields added to the default Pub form for a each Pub type.", + [MembershipType.community]: + "Selecting forms will give the member the ability to create Pubs in the community using the selected forms. If no forms are added, the contributor will not be able to create any Pubs, and will only be able to see Pubs they have access to either directly or at the stage level.", +}; + +export const MemberEditForm = ({ + member, + updateMember, + closeForm, + membershipType, + availableForms, +}: MemberEditDialogProps & { + closeForm: () => void; +}) => { + const runUpdateMember = useServerAction(updateMember); + + const form = useForm>({ + resolver: zodResolver(memberEditFormSchema), + defaultValues: { + role: member.role, + forms: member.forms, + }, + mode: "onChange", + }); + + async function onSubmit(data: z.infer) { + const result = await runUpdateMember({ + memberId: member.id, + role: data.role, + forms: data.forms, + }); + + if (didSucceed(result)) { + toast({ + title: "Success", + description: "Member added successfully", + }); + + closeForm(); + } + } + + const isContributor = form.watch("role") === MemberRole.contributor; + + return ( +
+ + { + <> + ( + + Role + + + Select the role for this user. +
    +
  • Admins can do anything.
  • +
  • Editors are able to edit most things
  • +
  • + Contributors are only able to see forms and other + public facing content that are linked to them +
  • +
+
+ +
+ )} + /> + {isContributor && !!availableForms.length && ( + { + const description = descriptions[membershipType]; + return ( + + Edit/View Access + + { + field.onChange(newValues); + }} + options={availableForms.map((f) => ({ + label: f.name, + value: f.id, + }))} + placeholder="Select forms" + /> + + {description} + + + ); + }} + /> + )} + + } + + + + ); +}; diff --git a/core/app/components/Memberships/memberInviteFormSchema.ts b/core/app/components/Memberships/memberInviteFormSchema.ts index c1c0a4157..03ae76709 100644 --- a/core/app/components/Memberships/memberInviteFormSchema.ts +++ b/core/app/components/Memberships/memberInviteFormSchema.ts @@ -12,3 +12,8 @@ export const memberInviteFormSchema = z.object({ isSuperAdmin: z.boolean().default(false).optional(), forms: z.array(formsIdSchema).default([]), }); + +export const memberEditFormSchema = z.object({ + role: z.nativeEnum(MemberRole).default(MemberRole.editor), + forms: z.array(formsIdSchema).default([]), +}); diff --git a/core/app/components/Memberships/types.ts b/core/app/components/Memberships/types.ts index b0f117104..69d79f040 100644 --- a/core/app/components/Memberships/types.ts +++ b/core/app/components/Memberships/types.ts @@ -1,9 +1,12 @@ import type { + CommunityMembershipsId, FormsId, MemberRole, MembershipType, NewUsers, + PubMembershipsId, PubsId, + StageMembershipsId, StagesId, UsersId, } from "db/public"; @@ -44,3 +47,23 @@ export type DialogProps = { membershipType: MembershipType; availableForms: { id: FormsId; name: string; isDefault: boolean }[]; }; + +export type MemberEditDialogProps = { + // There's probably a better type for these functions that should be server actions + updateMember: ({ + memberId, + role, + forms, + }: { + memberId: CommunityMembershipsId | StageMembershipsId | PubMembershipsId; + role: MemberRole; + forms: FormsId[]; + }) => Promise; + member: { + id: CommunityMembershipsId | StageMembershipsId | PubMembershipsId; + role: MemberRole; + forms: FormsId[]; + }; + membershipType: MembershipType; + availableForms: { id: FormsId; name: string; isDefault: boolean }[]; +}; From fdf50538a50e5bc5554d4f4e427433136fa6be22 Mon Sep 17 00:00:00 2001 From: Eric McDaniel Date: Mon, 15 Sep 2025 16:36:51 -0400 Subject: [PATCH 2/6] feat: pass available forms --- core/app/c/[communitySlug]/members/MemberTable.tsx | 12 ++++++++++-- .../members/getMemberTableColumns.tsx | 6 ++++-- core/app/c/[communitySlug]/members/page.tsx | 2 +- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/core/app/c/[communitySlug]/members/MemberTable.tsx b/core/app/c/[communitySlug]/members/MemberTable.tsx index 5fed62b76..374ea4480 100644 --- a/core/app/c/[communitySlug]/members/MemberTable.tsx +++ b/core/app/c/[communitySlug]/members/MemberTable.tsx @@ -2,11 +2,19 @@ import * as React from "react"; +import type { FormsId } from "db/public"; + import type { TableMember } from "./getMemberTableColumns"; import { DataTable } from "~/app/components/DataTable/DataTable"; import { getMemberTableColumns } from "./getMemberTableColumns"; -export const MemberTable = ({ members }: { members: TableMember[] }) => { - const memberTableColumns = getMemberTableColumns(); +export const MemberTable = ({ + members, + availableForms, +}: { + members: TableMember[]; + availableForms: { id: FormsId; name: string; isDefault: boolean }[]; +}) => { + const memberTableColumns = getMemberTableColumns(availableForms); return ; }; diff --git a/core/app/c/[communitySlug]/members/getMemberTableColumns.tsx b/core/app/c/[communitySlug]/members/getMemberTableColumns.tsx index 52c380fcb..9cb38161d 100644 --- a/core/app/c/[communitySlug]/members/getMemberTableColumns.tsx +++ b/core/app/c/[communitySlug]/members/getMemberTableColumns.tsx @@ -44,7 +44,9 @@ export type TableMember = { joined: string; }; -export const getMemberTableColumns = () => +export const getMemberTableColumns = ( + availableForms: { id: FormsId; name: string; isDefault: boolean }[] +) => [ { id: "select", @@ -182,7 +184,7 @@ export const getMemberTableColumns = () => }} updateMember={async () => {}} membershipType={MembershipType.community} - availableForms={[]} + availableForms={availableForms} /> diff --git a/core/app/c/[communitySlug]/members/page.tsx b/core/app/c/[communitySlug]/members/page.tsx index 204790594..caeb4ebc1 100644 --- a/core/app/c/[communitySlug]/members/page.tsx +++ b/core/app/c/[communitySlug]/members/page.tsx @@ -149,7 +149,7 @@ export default async function Page(props: { } >
- +
); From 92335b4929f4c766f94038bfa916dfe8bd8ad9aa Mon Sep 17 00:00:00 2001 From: Eric McDaniel Date: Tue, 16 Sep 2025 19:12:16 -0400 Subject: [PATCH 3/6] feat: update member role/forms via delete->insert --- .../c/[communitySlug]/members/MemberTable.tsx | 12 +++- core/app/c/[communitySlug]/members/actions.ts | 60 +++++++++++++++++++ .../members/getMemberTableColumns.tsx | 34 ++++++----- core/app/c/[communitySlug]/members/page.tsx | 9 ++- .../components/Memberships/MemberEditForm.tsx | 2 +- core/app/components/Memberships/types.ts | 9 +-- 6 files changed, 100 insertions(+), 26 deletions(-) diff --git a/core/app/c/[communitySlug]/members/MemberTable.tsx b/core/app/c/[communitySlug]/members/MemberTable.tsx index 374ea4480..b20ce95ec 100644 --- a/core/app/c/[communitySlug]/members/MemberTable.tsx +++ b/core/app/c/[communitySlug]/members/MemberTable.tsx @@ -11,10 +11,20 @@ import { getMemberTableColumns } from "./getMemberTableColumns"; export const MemberTable = ({ members, availableForms, + updateMember, }: { members: TableMember[]; availableForms: { id: FormsId; name: string; isDefault: boolean }[]; + updateMember: ({ + userId, + role, + forms, + }: { + userId: TableMember["id"]; + role: TableMember["role"]; + forms: FormsId[]; + }) => Promise; }) => { - const memberTableColumns = getMemberTableColumns(availableForms); + const memberTableColumns = getMemberTableColumns({ availableForms, updateMember }); return ; }; diff --git a/core/app/c/[communitySlug]/members/actions.ts b/core/app/c/[communitySlug]/members/actions.ts index 9c4eca09b..58455c0b5 100644 --- a/core/app/c/[communitySlug]/members/actions.ts +++ b/core/app/c/[communitySlug]/members/actions.ts @@ -7,6 +7,7 @@ import { MemberRole, MembershipType } from "db/public"; import type { TableMember } from "./getMemberTableColumns"; import { memberInviteFormSchema } from "~/app/components/Memberships/memberInviteFormSchema"; +import { db } from "~/kysely/database"; import { isUniqueConstraintError } from "~/kysely/errors"; import { getLoginData } from "~/lib/authentication/loginData"; import { isCommunityAdmin as isAdminOfCommunity } from "~/lib/authentication/roles"; @@ -195,3 +196,62 @@ export const removeMember = defineServerAction(async function removeMember({ }; } }); + +export const updateMember = defineServerAction(async function updateMember({ + userId, + role, + forms, +}: { + userId: UsersId; + role: MemberRole; + forms: FormsId[]; +}) { + try { + const { user, error: adminError, community } = await isCommunityAdmin(); + + if (adminError !== null) { + return { + title: "Failed to update member", + error: adminError, + }; + } + + console.log(userId, role, forms); + + const result = await db.transaction().execute(async (trx) => { + await deleteCommunityMemberships( + { + communityId: community.id, + userId, + }, + trx + ).execute(); + + return insertCommunityMemberships( + { + communityId: community.id, + userId, + role, + forms: role === MemberRole.contributor ? forms : [], + }, + trx + ).execute(); + }); + + if (!result) { + return { + title: "Failed to update member", + error: "An unexpected error occurred", + }; + } + + return { success: true }; + } catch (error) { + console.error(error); + return { + title: "Failed to update member", + error: "An unexpected error occurred", + cause: error, + }; + } +}); diff --git a/core/app/c/[communitySlug]/members/getMemberTableColumns.tsx b/core/app/c/[communitySlug]/members/getMemberTableColumns.tsx index 9cb38161d..f840493c8 100644 --- a/core/app/c/[communitySlug]/members/getMemberTableColumns.tsx +++ b/core/app/c/[communitySlug]/members/getMemberTableColumns.tsx @@ -2,13 +2,7 @@ import type { ColumnDef } from "@tanstack/react-table"; -import type { - CommunityMembershipsId, - FormsId, - PubMembershipsId, - StageMembershipsId, - UsersId, -} from "db/public"; +import type { FormsId, UsersId } from "db/public"; import { MemberRole, MembershipType } from "db/public"; import { Avatar, AvatarFallback, AvatarImage } from "ui/avatar"; import { Badge } from "ui/badge"; @@ -30,7 +24,6 @@ import { RemoveMemberButton } from "./RemoveMemberButton"; export type TableMember = { id: UsersId; - memberId: CommunityMembershipsId | StageMembershipsId | PubMembershipsId; avatar: string | null; email: string; firstName: string; @@ -44,9 +37,20 @@ export type TableMember = { joined: string; }; -export const getMemberTableColumns = ( - availableForms: { id: FormsId; name: string; isDefault: boolean }[] -) => +type TableColumnsProps = { + availableForms: { id: FormsId; name: string; isDefault: boolean }[]; + updateMember: ({ + userId, + role, + forms, + }: { + userId: UsersId; + role: MemberRole; + forms: FormsId[]; + }) => Promise; +}; + +export const getMemberTableColumns = (props: TableColumnsProps) => [ { id: "select", @@ -160,7 +164,7 @@ export const getMemberTableColumns = ( { id: "actions", enableHiding: false, - cell: ({ row, table }) => { + cell: ({ row }) => { return ( @@ -178,13 +182,13 @@ export const getMemberTableColumns = (
form.id) ?? [], }} - updateMember={async () => {}} + updateMember={props.updateMember} membershipType={MembershipType.community} - availableForms={availableForms} + availableForms={props.availableForms} />
diff --git a/core/app/c/[communitySlug]/members/page.tsx b/core/app/c/[communitySlug]/members/page.tsx index caeb4ebc1..13791eef5 100644 --- a/core/app/c/[communitySlug]/members/page.tsx +++ b/core/app/c/[communitySlug]/members/page.tsx @@ -15,7 +15,7 @@ import { findCommunityBySlug } from "~/lib/server/community"; import { getSimpleForms } from "~/lib/server/form"; import { selectAllCommunityMemberships } from "~/lib/server/member"; import { ContentLayout } from "../ContentLayout"; -import { addMember, createUserWithCommunityMembership } from "./actions"; +import { addMember, createUserWithCommunityMembership, updateMember } from "./actions"; import { MemberTable } from "./MemberTable"; export const metadata: Metadata = { @@ -69,7 +69,6 @@ export default async function Page(props: { const { id, createdAt, user, role } = member; return { id: user.id, - memberId: id, avatar: user.avatar, firstName: user.firstName, lastName: user.lastName, @@ -149,7 +148,11 @@ export default async function Page(props: { } >
- +
); diff --git a/core/app/components/Memberships/MemberEditForm.tsx b/core/app/components/Memberships/MemberEditForm.tsx index 3bb9a4c7f..2a944659e 100644 --- a/core/app/components/Memberships/MemberEditForm.tsx +++ b/core/app/components/Memberships/MemberEditForm.tsx @@ -56,7 +56,7 @@ export const MemberEditForm = ({ async function onSubmit(data: z.infer) { const result = await runUpdateMember({ - memberId: member.id, + userId: member.userId, role: data.role, forms: data.forms, }); diff --git a/core/app/components/Memberships/types.ts b/core/app/components/Memberships/types.ts index 69d79f040..8dd657e5d 100644 --- a/core/app/components/Memberships/types.ts +++ b/core/app/components/Memberships/types.ts @@ -1,12 +1,9 @@ import type { - CommunityMembershipsId, FormsId, MemberRole, MembershipType, NewUsers, - PubMembershipsId, PubsId, - StageMembershipsId, StagesId, UsersId, } from "db/public"; @@ -51,16 +48,16 @@ export type DialogProps = { export type MemberEditDialogProps = { // There's probably a better type for these functions that should be server actions updateMember: ({ - memberId, + userId, role, forms, }: { - memberId: CommunityMembershipsId | StageMembershipsId | PubMembershipsId; + userId: UsersId; role: MemberRole; forms: FormsId[]; }) => Promise; member: { - id: CommunityMembershipsId | StageMembershipsId | PubMembershipsId; + userId: UsersId; role: MemberRole; forms: FormsId[]; }; From ac416866e7f04e6a6c17055a3fdc44bbc07adc38 Mon Sep 17 00:00:00 2001 From: Eric McDaniel Date: Wed, 17 Sep 2025 14:10:45 -0400 Subject: [PATCH 4/6] feat: rough pub and stage membership edit dialogs --- core/app/c/[communitySlug]/members/actions.ts | 3 - .../c/[communitySlug]/pubs/[pubId]/actions.ts | 84 +++++++++++++++- .../c/[communitySlug]/pubs/[pubId]/page.tsx | 2 + .../[communitySlug]/stages/manage/actions.ts | 99 ++++++++++++++++++- .../components/panel/StagePanelMembers.tsx | 2 + .../Memberships/EditMemberDialog.tsx | 7 +- .../components/Memberships/MembersList.tsx | 15 +++ core/app/components/Memberships/types.ts | 6 ++ core/lib/server/member.ts | 14 +++ 9 files changed, 221 insertions(+), 11 deletions(-) diff --git a/core/app/c/[communitySlug]/members/actions.ts b/core/app/c/[communitySlug]/members/actions.ts index 58455c0b5..7d1282391 100644 --- a/core/app/c/[communitySlug]/members/actions.ts +++ b/core/app/c/[communitySlug]/members/actions.ts @@ -216,8 +216,6 @@ export const updateMember = defineServerAction(async function updateMember({ }; } - console.log(userId, role, forms); - const result = await db.transaction().execute(async (trx) => { await deleteCommunityMemberships( { @@ -247,7 +245,6 @@ export const updateMember = defineServerAction(async function updateMember({ return { success: true }; } catch (error) { - console.error(error); return { title: "Failed to update member", error: "An unexpected error occurred", diff --git a/core/app/c/[communitySlug]/pubs/[pubId]/actions.ts b/core/app/c/[communitySlug]/pubs/[pubId]/actions.ts index 794975f7d..af14564f3 100644 --- a/core/app/c/[communitySlug]/pubs/[pubId]/actions.ts +++ b/core/app/c/[communitySlug]/pubs/[pubId]/actions.ts @@ -1,15 +1,17 @@ "use server"; -import type { FormsId, MemberRole, PubsId, UsersId } from "db/public"; -import { Capabilities, MembershipType } from "db/public"; +import type { FormsId, PubsId, UsersId } from "db/public"; +import { Capabilities, MemberRole, MembershipType } from "db/public"; import { db } from "~/kysely/database"; import { isUniqueConstraintError } from "~/kysely/errors"; import { getLoginData } from "~/lib/authentication/loginData"; import { userCan } from "~/lib/authorization/capabilities"; +import { ApiError } from "~/lib/server"; import { autoRevalidate } from "~/lib/server/cache/autoRevalidate"; +import { findCommunityBySlug } from "~/lib/server/community"; import { defineServerAction } from "~/lib/server/defineServerAction"; -import { insertPubMemberships } from "~/lib/server/member"; +import { deletePubMemberships, insertPubMemberships } from "~/lib/server/member"; import { createUserWithMemberships } from "~/lib/server/user"; export const removePubMember = defineServerAction(async function removePubMember( @@ -81,6 +83,82 @@ export const addPubMember = defineServerAction(async function addPubMember( } }); +export const updatePubMember = defineServerAction(async function updatePubMember({ + userId, + role, + forms, + targetId, +}: { + userId: UsersId; + role: MemberRole; + forms: FormsId[]; + targetId: PubsId; +}) { + try { + const [{ user }, community] = await Promise.all([getLoginData(), findCommunityBySlug()]); + + if (!user) { + return { + error: ApiError.NOT_LOGGED_IN, + }; + } + + if (!community) { + return { + error: ApiError.COMMUNITY_NOT_FOUND, + }; + } + + if ( + !(await userCan( + Capabilities.removePubMember, + { type: MembershipType.pub, pubId: targetId }, + user.id + )) + ) { + return { + title: "Unauthorized", + error: "You are not authorized to update a stage member", + }; + } + + const result = await db.transaction().execute(async (trx) => { + await deletePubMemberships( + { + pubId: targetId, + userId, + }, + trx + ).execute(); + + return insertPubMemberships( + { + pubId: targetId, + userId, + role, + forms: role === MemberRole.contributor ? forms : [], + }, + trx + ).execute(); + }); + + if (!result) { + return { + title: "Failed to update member", + error: "An unexpected error occurred", + }; + } + + return { success: true }; + } catch (error) { + return { + title: "Failed to update member", + error: "An unexpected error occurred", + cause: error, + }; + } +}); + export const addUserWithPubMembership = defineServerAction(async function addUserWithPubMembership( pubId: PubsId, data: { diff --git a/core/app/c/[communitySlug]/pubs/[pubId]/page.tsx b/core/app/c/[communitySlug]/pubs/[pubId]/page.tsx index 424cd38a9..29d7192dc 100644 --- a/core/app/c/[communitySlug]/pubs/[pubId]/page.tsx +++ b/core/app/c/[communitySlug]/pubs/[pubId]/page.tsx @@ -44,6 +44,7 @@ import { addUserWithPubMembership, removePubMember, setPubMemberRole, + updatePubMember, } from "./actions"; import { PubValues } from "./components/PubValues"; import { RelatedPubsTableWrapper } from "./components/RelatedPubsTableWrapper"; @@ -334,6 +335,7 @@ export default async function Page(props: { members={pub.members} setRole={setPubMemberRole} removeMember={removePubMember} + updateMember={updatePubMember} targetId={pubId} readOnly={!canRemoveMember} /> diff --git a/core/app/c/[communitySlug]/stages/manage/actions.ts b/core/app/c/[communitySlug]/stages/manage/actions.ts index f86a83329..a49ef4d2a 100644 --- a/core/app/c/[communitySlug]/stages/manage/actions.ts +++ b/core/app/c/[communitySlug]/stages/manage/actions.ts @@ -7,12 +7,11 @@ import type { ActionInstancesId, CommunitiesId, FormsId, - MemberRole, RulesId, StagesId, UsersId, } from "db/public"; -import { Capabilities, Event, MembershipType, stagesIdSchema } from "db/public"; +import { Capabilities, Event, MemberRole, MembershipType, stagesIdSchema } from "db/public"; import { logger } from "logger"; import type { CreateRuleSchema } from "./components/panel/actionsTab/StagePanelRuleCreator"; @@ -73,6 +72,26 @@ async function deleteMoveConstraints(moveConstraintIds: StagesId[]) { ).execute(); } +function deleteStageMemberships( + params: { communityId: CommunitiesId; userId: UsersId }, + trx?: typeof db +) { + const executor = trx ?? db; + return autoRevalidate( + executor + .deleteFrom("stage_memberships") + .where("stage_memberships.userId", "=", params.userId) + .where( + "stage_memberships.stageId", + "in", + executor + .selectFrom("stages") + .select("stages.id") + .where("stages.communityId", "=", params.communityId) + ) + ); +} + export const createStage = defineServerAction(async function createStage( communityId: CommunitiesId, id: StagesId @@ -561,6 +580,82 @@ export const addStageMember = defineServerAction(async function addStageMember( } }); +export const updateStageMember = defineServerAction(async function updateStageMember({ + userId, + role, + forms, + targetId, +}: { + userId: UsersId; + role: MemberRole; + forms: FormsId[]; + targetId: StagesId; +}) { + try { + const [{ user }, community] = await Promise.all([getLoginData(), findCommunityBySlug()]); + + if (!user) { + return { + error: ApiError.NOT_LOGGED_IN, + }; + } + + if (!community) { + return { + error: ApiError.COMMUNITY_NOT_FOUND, + }; + } + + if ( + !(await userCan( + Capabilities.removeStageMember, + { type: MembershipType.stage, stageId: targetId }, + user.id + )) + ) { + return { + title: "Unauthorized", + error: "You are not authorized to update a stage member", + }; + } + + const result = await db.transaction().execute(async (trx) => { + await deleteStageMemberships( + { + communityId: community.id, + userId, + }, + trx + ).execute(); + + return insertStageMemberships( + { + stageId: targetId, + userId, + role, + forms: role === MemberRole.contributor ? forms : [], + }, + trx + ).execute(); + }); + + if (!result) { + return { + title: "Failed to update member", + error: "An unexpected error occurred", + }; + } + + return { success: true }; + } catch (error) { + return { + title: "Failed to update member", + error: "An unexpected error occurred", + cause: error, + }; + } +}); + export const addUserWithStageMembership = defineServerAction( async function addUserWithStageMembership( stageId: StagesId, diff --git a/core/app/c/[communitySlug]/stages/manage/components/panel/StagePanelMembers.tsx b/core/app/c/[communitySlug]/stages/manage/components/panel/StagePanelMembers.tsx index 74988a1f6..ddc3dee47 100644 --- a/core/app/c/[communitySlug]/stages/manage/components/panel/StagePanelMembers.tsx +++ b/core/app/c/[communitySlug]/stages/manage/components/panel/StagePanelMembers.tsx @@ -18,6 +18,7 @@ import { addUserWithStageMembership, removeStageMember, setStageMemberRole, + updateStageMember, } from "../../actions"; type PropsInner = { @@ -55,6 +56,7 @@ const StagePanelMembersInner = async ({ stageId, user }: PropsInner) => { members={members} setRole={setStageMemberRole} removeMember={removeStageMember} + updateMember={updateStageMember} targetId={stageId} readOnly={!canManage} /> diff --git a/core/app/components/Memberships/EditMemberDialog.tsx b/core/app/components/Memberships/EditMemberDialog.tsx index 69efbdb3e..b292f35c7 100644 --- a/core/app/components/Memberships/EditMemberDialog.tsx +++ b/core/app/components/Memberships/EditMemberDialog.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { UserCog } from "lucide-react"; import { Button } from "ui/button"; import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "ui/dialog"; @@ -16,14 +17,14 @@ export const EditMemberDialog = (props: MemberEditDialogProps & { className?: st return ( - Edit a user in your community + Edit Member diff --git a/core/app/components/Memberships/MembersList.tsx b/core/app/components/Memberships/MembersList.tsx index e8cf8065f..6867945a6 100644 --- a/core/app/components/Memberships/MembersList.tsx +++ b/core/app/components/Memberships/MembersList.tsx @@ -3,10 +3,12 @@ import { useMemo } from "react"; import type { UsersId } from "db/public"; +import { MembershipType } from "db/public"; import { Avatar, AvatarFallback, AvatarImage } from "ui/avatar"; import type { MembersListProps, TargetId } from "./types"; import { compareMemberRoles } from "~/lib/authorization/rolesRanking"; +import { EditMemberDialog } from "./EditMemberDialog"; import { RemoveMemberButton } from "./RemoveMemberButton"; import { RoleSelect } from "./RoleSelect"; @@ -14,6 +16,7 @@ export const MembersList = ({ members, setRole, removeMember, + updateMember, targetId, readOnly, }: MembersListProps) => { @@ -68,6 +71,18 @@ export const MembersList = ({ targetId={targetId} removeMember={removeMember} /> + + + updateMember({ + ...member, + targetId, + }) + } + /> )} diff --git a/core/app/components/Memberships/types.ts b/core/app/components/Memberships/types.ts index 8dd657e5d..a5ec75a63 100644 --- a/core/app/components/Memberships/types.ts +++ b/core/app/components/Memberships/types.ts @@ -16,6 +16,12 @@ export type MembersListProps = { members: (SafeUser & { role: MemberRole })[]; setRole: (targetId: T, role: MemberRole, userId: UsersId) => Promise; removeMember: (userId: UsersId, targetId: T) => Promise; + updateMember: (params: { + userId: UsersId; + role: MemberRole; + forms: FormsId[]; + targetId: T; + }) => Promise; readOnly: boolean; targetId: T; }; diff --git a/core/lib/server/member.ts b/core/lib/server/member.ts index 45c057d84..79af51260 100644 --- a/core/lib/server/member.ts +++ b/core/lib/server/member.ts @@ -189,6 +189,20 @@ export const deleteCommunityMemberships = ( .returningAll() ); +export const deletePubMemberships = ( + { userId, pubId }: { userId: UsersId; pubId: PubsId }, + trx = db +) => + autoRevalidate( + trx + .deleteFrom("pub_memberships") + .using("users") + .whereRef("users.id", "=", "pub_memberships.userId") + .where("users.id", "=", userId) + .where("pub_memberships.pubId", "=", pubId) + .returningAll() + ); + export const deleteCommunityMember = (props: CommunityMembershipsId, trx = db) => autoRevalidate(trx.deleteFrom("community_memberships").where("id", "=", props).returningAll()); From f5aac74dee85b5e27b5469d6796d30eefbf2b026 Mon Sep 17 00:00:00 2001 From: Eric McDaniel Date: Thu, 18 Sep 2025 10:18:19 -0400 Subject: [PATCH 5/6] feat: pass forms to member list --- core/app/c/[communitySlug]/pubs/[pubId]/page.tsx | 1 + .../manage/components/panel/StagePanelMembers.tsx | 1 + core/app/components/Memberships/EditMemberDialog.tsx | 4 ++-- core/app/components/Memberships/MembersList.tsx | 12 +++--------- core/app/components/Memberships/types.ts | 2 ++ 5 files changed, 9 insertions(+), 11 deletions(-) diff --git a/core/app/c/[communitySlug]/pubs/[pubId]/page.tsx b/core/app/c/[communitySlug]/pubs/[pubId]/page.tsx index 29d7192dc..46bdab4a4 100644 --- a/core/app/c/[communitySlug]/pubs/[pubId]/page.tsx +++ b/core/app/c/[communitySlug]/pubs/[pubId]/page.tsx @@ -338,6 +338,7 @@ export default async function Page(props: { updateMember={updatePubMember} targetId={pubId} readOnly={!canRemoveMember} + availableForms={availableViewForms} /> diff --git a/core/app/c/[communitySlug]/stages/manage/components/panel/StagePanelMembers.tsx b/core/app/c/[communitySlug]/stages/manage/components/panel/StagePanelMembers.tsx index ddc3dee47..c3a9459ca 100644 --- a/core/app/c/[communitySlug]/stages/manage/components/panel/StagePanelMembers.tsx +++ b/core/app/c/[communitySlug]/stages/manage/components/panel/StagePanelMembers.tsx @@ -59,6 +59,7 @@ const StagePanelMembersInner = async ({ stageId, user }: PropsInner) => { updateMember={updateStageMember} targetId={stageId} readOnly={!canManage} + availableForms={availableForms} /> diff --git a/core/app/components/Memberships/EditMemberDialog.tsx b/core/app/components/Memberships/EditMemberDialog.tsx index b292f35c7..61761939e 100644 --- a/core/app/components/Memberships/EditMemberDialog.tsx +++ b/core/app/components/Memberships/EditMemberDialog.tsx @@ -5,7 +5,6 @@ import { UserCog } from "lucide-react"; import { Button } from "ui/button"; import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "ui/dialog"; -import { UserPlus } from "ui/icon"; import { Tooltip, TooltipContent, TooltipTrigger } from "ui/tooltip"; import { cn } from "utils"; @@ -24,7 +23,8 @@ export const EditMemberDialog = (props: MemberEditDialogProps & { className?: st variant="ghost" className={cn("inline-flex items-center gap-x-2", props.className)} > - Edit member + {!props.minimal && <>Edit member } + diff --git a/core/app/components/Memberships/MembersList.tsx b/core/app/components/Memberships/MembersList.tsx index 6867945a6..0e87fc8ea 100644 --- a/core/app/components/Memberships/MembersList.tsx +++ b/core/app/components/Memberships/MembersList.tsx @@ -19,6 +19,7 @@ export const MembersList = ({ updateMember, targetId, readOnly, + availableForms, }: MembersListProps) => { const dedupedMembers = useMemo(() => { const dedupedMembers = new Map["members"][number]>(); @@ -59,29 +60,22 @@ export const MembersList = ({ ) : ( <> - - - updateMember({ ...member, targetId, }) } + minimal /> )} diff --git a/core/app/components/Memberships/types.ts b/core/app/components/Memberships/types.ts index a5ec75a63..56f53465a 100644 --- a/core/app/components/Memberships/types.ts +++ b/core/app/components/Memberships/types.ts @@ -24,6 +24,7 @@ export type MembersListProps = { }) => Promise; readOnly: boolean; targetId: T; + availableForms: { id: FormsId; name: string; isDefault: boolean }[]; }; export type DialogProps = { @@ -69,4 +70,5 @@ export type MemberEditDialogProps = { }; membershipType: MembershipType; availableForms: { id: FormsId; name: string; isDefault: boolean }[]; + minimal?: boolean; }; From 63dcc277980588ca99bd14d5fcf5005bca306f30 Mon Sep 17 00:00:00 2001 From: Eric McDaniel Date: Thu, 18 Sep 2025 13:32:09 -0400 Subject: [PATCH 6/6] feat: meets reqs but needs love --- .../c/[communitySlug]/members/MemberTable.tsx | 16 +- core/app/c/[communitySlug]/members/actions.ts | 59 +----- .../members/getMemberTableColumns.tsx | 16 +- core/app/c/[communitySlug]/members/page.tsx | 4 +- .../c/[communitySlug]/pubs/[pubId]/actions.ts | 80 +------ .../c/[communitySlug]/pubs/[pubId]/page.tsx | 5 +- .../[communitySlug]/stages/manage/actions.ts | 96 --------- .../components/panel/StagePanelMembers.tsx | 3 +- .../components/Memberships/MemberEditForm.tsx | 7 +- .../components/Memberships/MembersList.tsx | 81 +++++--- .../app/components/Memberships/RoleSelect.tsx | 86 -------- core/app/components/Memberships/actions.ts | 195 ++++++++++++++++++ core/app/components/Memberships/types.ts | 23 +-- core/lib/db/queries.ts | 2 +- core/lib/server/member.ts | 20 ++ core/lib/server/pub.ts | 7 +- packages/contracts/src/resources/types.ts | 10 +- 17 files changed, 307 insertions(+), 403 deletions(-) delete mode 100644 core/app/components/Memberships/RoleSelect.tsx create mode 100644 core/app/components/Memberships/actions.ts diff --git a/core/app/c/[communitySlug]/members/MemberTable.tsx b/core/app/c/[communitySlug]/members/MemberTable.tsx index b20ce95ec..6bec66d64 100644 --- a/core/app/c/[communitySlug]/members/MemberTable.tsx +++ b/core/app/c/[communitySlug]/members/MemberTable.tsx @@ -2,7 +2,7 @@ import * as React from "react"; -import type { FormsId } from "db/public"; +import type { CommunitiesId, FormsId } from "db/public"; import type { TableMember } from "./getMemberTableColumns"; import { DataTable } from "~/app/components/DataTable/DataTable"; @@ -11,20 +11,12 @@ import { getMemberTableColumns } from "./getMemberTableColumns"; export const MemberTable = ({ members, availableForms, - updateMember, + communityId, }: { members: TableMember[]; availableForms: { id: FormsId; name: string; isDefault: boolean }[]; - updateMember: ({ - userId, - role, - forms, - }: { - userId: TableMember["id"]; - role: TableMember["role"]; - forms: FormsId[]; - }) => Promise; + communityId: CommunitiesId; }) => { - const memberTableColumns = getMemberTableColumns({ availableForms, updateMember }); + const memberTableColumns = getMemberTableColumns({ availableForms, communityId }); return ; }; diff --git a/core/app/c/[communitySlug]/members/actions.ts b/core/app/c/[communitySlug]/members/actions.ts index 7d1282391..6ba9e5a4d 100644 --- a/core/app/c/[communitySlug]/members/actions.ts +++ b/core/app/c/[communitySlug]/members/actions.ts @@ -7,7 +7,6 @@ import { MemberRole, MembershipType } from "db/public"; import type { TableMember } from "./getMemberTableColumns"; import { memberInviteFormSchema } from "~/app/components/Memberships/memberInviteFormSchema"; -import { db } from "~/kysely/database"; import { isUniqueConstraintError } from "~/kysely/errors"; import { getLoginData } from "~/lib/authentication/loginData"; import { isCommunityAdmin as isAdminOfCommunity } from "~/lib/authentication/roles"; @@ -166,7 +165,7 @@ export const removeMember = defineServerAction(async function removeMember({ member: TableMember; }) { try { - const { user, error: adminError, community } = await isCommunityAdmin(); + const { error: adminError, community } = await isCommunityAdmin(); if (adminError !== null) { return { @@ -196,59 +195,3 @@ export const removeMember = defineServerAction(async function removeMember({ }; } }); - -export const updateMember = defineServerAction(async function updateMember({ - userId, - role, - forms, -}: { - userId: UsersId; - role: MemberRole; - forms: FormsId[]; -}) { - try { - const { user, error: adminError, community } = await isCommunityAdmin(); - - if (adminError !== null) { - return { - title: "Failed to update member", - error: adminError, - }; - } - - const result = await db.transaction().execute(async (trx) => { - await deleteCommunityMemberships( - { - communityId: community.id, - userId, - }, - trx - ).execute(); - - return insertCommunityMemberships( - { - communityId: community.id, - userId, - role, - forms: role === MemberRole.contributor ? forms : [], - }, - trx - ).execute(); - }); - - if (!result) { - return { - title: "Failed to update member", - error: "An unexpected error occurred", - }; - } - - return { success: true }; - } catch (error) { - return { - title: "Failed to update member", - error: "An unexpected error occurred", - cause: error, - }; - } -}); diff --git a/core/app/c/[communitySlug]/members/getMemberTableColumns.tsx b/core/app/c/[communitySlug]/members/getMemberTableColumns.tsx index f840493c8..c2487c6e4 100644 --- a/core/app/c/[communitySlug]/members/getMemberTableColumns.tsx +++ b/core/app/c/[communitySlug]/members/getMemberTableColumns.tsx @@ -2,7 +2,7 @@ import type { ColumnDef } from "@tanstack/react-table"; -import type { FormsId, UsersId } from "db/public"; +import type { CommunitiesId, FormsId, UsersId } from "db/public"; import { MemberRole, MembershipType } from "db/public"; import { Avatar, AvatarFallback, AvatarImage } from "ui/avatar"; import { Badge } from "ui/badge"; @@ -39,15 +39,7 @@ export type TableMember = { type TableColumnsProps = { availableForms: { id: FormsId; name: string; isDefault: boolean }[]; - updateMember: ({ - userId, - role, - forms, - }: { - userId: UsersId; - role: MemberRole; - forms: FormsId[]; - }) => Promise; + communityId: CommunitiesId; }; export const getMemberTableColumns = (props: TableColumnsProps) => @@ -181,14 +173,14 @@ export const getMemberTableColumns = (props: TableColumnsProps) =>
form.id) ?? [], }} - updateMember={props.updateMember} membershipType={MembershipType.community} - availableForms={props.availableForms} + membershipTargetId={props.communityId} />
diff --git a/core/app/c/[communitySlug]/members/page.tsx b/core/app/c/[communitySlug]/members/page.tsx index 13791eef5..e36865626 100644 --- a/core/app/c/[communitySlug]/members/page.tsx +++ b/core/app/c/[communitySlug]/members/page.tsx @@ -15,7 +15,7 @@ import { findCommunityBySlug } from "~/lib/server/community"; import { getSimpleForms } from "~/lib/server/form"; import { selectAllCommunityMemberships } from "~/lib/server/member"; import { ContentLayout } from "../ContentLayout"; -import { addMember, createUserWithCommunityMembership, updateMember } from "./actions"; +import { addMember, createUserWithCommunityMembership } from "./actions"; import { MemberTable } from "./MemberTable"; export const metadata: Metadata = { @@ -151,7 +151,7 @@ export default async function Page(props: { diff --git a/core/app/c/[communitySlug]/pubs/[pubId]/actions.ts b/core/app/c/[communitySlug]/pubs/[pubId]/actions.ts index af14564f3..6cabcfad2 100644 --- a/core/app/c/[communitySlug]/pubs/[pubId]/actions.ts +++ b/core/app/c/[communitySlug]/pubs/[pubId]/actions.ts @@ -7,11 +7,9 @@ import { db } from "~/kysely/database"; import { isUniqueConstraintError } from "~/kysely/errors"; import { getLoginData } from "~/lib/authentication/loginData"; import { userCan } from "~/lib/authorization/capabilities"; -import { ApiError } from "~/lib/server"; import { autoRevalidate } from "~/lib/server/cache/autoRevalidate"; -import { findCommunityBySlug } from "~/lib/server/community"; import { defineServerAction } from "~/lib/server/defineServerAction"; -import { deletePubMemberships, insertPubMemberships } from "~/lib/server/member"; +import { insertPubMemberships } from "~/lib/server/member"; import { createUserWithMemberships } from "~/lib/server/user"; export const removePubMember = defineServerAction(async function removePubMember( @@ -83,82 +81,6 @@ export const addPubMember = defineServerAction(async function addPubMember( } }); -export const updatePubMember = defineServerAction(async function updatePubMember({ - userId, - role, - forms, - targetId, -}: { - userId: UsersId; - role: MemberRole; - forms: FormsId[]; - targetId: PubsId; -}) { - try { - const [{ user }, community] = await Promise.all([getLoginData(), findCommunityBySlug()]); - - if (!user) { - return { - error: ApiError.NOT_LOGGED_IN, - }; - } - - if (!community) { - return { - error: ApiError.COMMUNITY_NOT_FOUND, - }; - } - - if ( - !(await userCan( - Capabilities.removePubMember, - { type: MembershipType.pub, pubId: targetId }, - user.id - )) - ) { - return { - title: "Unauthorized", - error: "You are not authorized to update a stage member", - }; - } - - const result = await db.transaction().execute(async (trx) => { - await deletePubMemberships( - { - pubId: targetId, - userId, - }, - trx - ).execute(); - - return insertPubMemberships( - { - pubId: targetId, - userId, - role, - forms: role === MemberRole.contributor ? forms : [], - }, - trx - ).execute(); - }); - - if (!result) { - return { - title: "Failed to update member", - error: "An unexpected error occurred", - }; - } - - return { success: true }; - } catch (error) { - return { - title: "Failed to update member", - error: "An unexpected error occurred", - cause: error, - }; - } -}); - export const addUserWithPubMembership = defineServerAction(async function addUserWithPubMembership( pubId: PubsId, data: { diff --git a/core/app/c/[communitySlug]/pubs/[pubId]/page.tsx b/core/app/c/[communitySlug]/pubs/[pubId]/page.tsx index 46bdab4a4..96595fb12 100644 --- a/core/app/c/[communitySlug]/pubs/[pubId]/page.tsx +++ b/core/app/c/[communitySlug]/pubs/[pubId]/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import { cache } from "react"; import Link from "next/link"; -import { notFound, redirect } from "next/navigation"; +import { notFound } from "next/navigation"; import { BookOpen, Eye } from "lucide-react"; import type { CommunitiesId, PubsId } from "db/public"; @@ -44,7 +44,6 @@ import { addUserWithPubMembership, removePubMember, setPubMemberRole, - updatePubMember, } from "./actions"; import { PubValues } from "./components/PubValues"; import { RelatedPubsTableWrapper } from "./components/RelatedPubsTableWrapper"; @@ -333,9 +332,9 @@ export default async function Page(props: { { - await deleteStageMemberships( - { - communityId: community.id, - userId, - }, - trx - ).execute(); - - return insertStageMemberships( - { - stageId: targetId, - userId, - role, - forms: role === MemberRole.contributor ? forms : [], - }, - trx - ).execute(); - }); - - if (!result) { - return { - title: "Failed to update member", - error: "An unexpected error occurred", - }; - } - - return { success: true }; - } catch (error) { - return { - title: "Failed to update member", - error: "An unexpected error occurred", - cause: error, - }; - } -}); - export const addUserWithStageMembership = defineServerAction( async function addUserWithStageMembership( stageId: StagesId, diff --git a/core/app/c/[communitySlug]/stages/manage/components/panel/StagePanelMembers.tsx b/core/app/c/[communitySlug]/stages/manage/components/panel/StagePanelMembers.tsx index c3a9459ca..5da330505 100644 --- a/core/app/c/[communitySlug]/stages/manage/components/panel/StagePanelMembers.tsx +++ b/core/app/c/[communitySlug]/stages/manage/components/panel/StagePanelMembers.tsx @@ -18,7 +18,6 @@ import { addUserWithStageMembership, removeStageMember, setStageMemberRole, - updateStageMember, } from "../../actions"; type PropsInner = { @@ -54,9 +53,9 @@ const StagePanelMembersInner = async ({ stageId, user }: PropsInner) => { = { @@ -36,8 +37,8 @@ export const descriptions: Record = { export const MemberEditForm = ({ member, - updateMember, closeForm, + membershipTargetId, membershipType, availableForms, }: MemberEditDialogProps & { @@ -59,12 +60,14 @@ export const MemberEditForm = ({ userId: member.userId, role: data.role, forms: data.forms, + targetId: membershipTargetId, + targetType: membershipType, }); if (didSucceed(result)) { toast({ title: "Success", - description: "Member added successfully", + description: "Member updated successfully", }); closeForm(); diff --git a/core/app/components/Memberships/MembersList.tsx b/core/app/components/Memberships/MembersList.tsx index 0e87fc8ea..1a1236be3 100644 --- a/core/app/components/Memberships/MembersList.tsx +++ b/core/app/components/Memberships/MembersList.tsx @@ -2,42 +2,72 @@ import { useMemo } from "react"; -import type { UsersId } from "db/public"; -import { MembershipType } from "db/public"; +import type { FormsId, UsersId } from "db/public"; +import { MemberRole } from "db/public"; import { Avatar, AvatarFallback, AvatarImage } from "ui/avatar"; import type { MembersListProps, TargetId } from "./types"; +import type { SafeUser } from "~/lib/server/user"; import { compareMemberRoles } from "~/lib/authorization/rolesRanking"; import { EditMemberDialog } from "./EditMemberDialog"; import { RemoveMemberButton } from "./RemoveMemberButton"; -import { RoleSelect } from "./RoleSelect"; + +const dedupeMembers = ( + members: (SafeUser & { + role: MemberRole; + formId: FormsId | null; + })[], + availableForms: { + id: FormsId; + name: string; + isDefault: boolean; + }[] +) => { + const dedupedMembersMap = new Map(); + for (const { formId, ...member } of members) { + const correspondingForm = availableForms.find((f) => f.id === formId); + if (!dedupedMembersMap.has(member.id)) { + const forms = + correspondingForm && member.role === MemberRole.contributor + ? [correspondingForm.id] + : []; + dedupedMembersMap.set(member.id, { + ...member, + forms, + }); + continue; + } + + const m = dedupedMembersMap.get(member.id); + + if (m && m.role === MemberRole.contributor && member.role === MemberRole.contributor) { + const forms = [...(m.forms ?? []), ...(correspondingForm ? [formId] : [])]; + dedupedMembersMap.set(member.id, { + ...member, + forms, + }); + continue; + } + + if (m && compareMemberRoles(member.role, ">", m.role)) { + dedupedMembersMap.set(member.id, m); + } + } + return dedupedMembersMap; +}; export const MembersList = ({ members, - setRole, + membershipType, removeMember, - updateMember, targetId, readOnly, availableForms, }: MembersListProps) => { - const dedupedMembers = useMemo(() => { - const dedupedMembers = new Map["members"][number]>(); - for (const member of members) { - if (!dedupedMembers.has(member.id)) { - dedupedMembers.set(member.id, member); - } else { - const m = dedupedMembers.get(member.id); - if (m && compareMemberRoles(member.role, ">", m.role)) { - dedupedMembers.set(member.id, m); - } - } - } - return dedupedMembers; - }, [members]); + const finalMembers = dedupeMembers(members, availableForms); return ( <> - {[...dedupedMembers.values()].map((user) => ( + {[...finalMembers.values()].map((user) => (
@@ -66,15 +96,10 @@ export const MembersList = ({ removeMember={removeMember} /> - updateMember({ - ...member, - targetId, - }) - } minimal /> diff --git a/core/app/components/Memberships/RoleSelect.tsx b/core/app/components/Memberships/RoleSelect.tsx deleted file mode 100644 index 22ac4df9d..000000000 --- a/core/app/components/Memberships/RoleSelect.tsx +++ /dev/null @@ -1,86 +0,0 @@ -"use client"; - -import type { UsersId } from "db/public"; -import { MemberRole } from "db/public"; -import { Button } from "ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "ui/dropdown-menu"; -import { toast } from "ui/use-toast"; - -import type { MembersListProps, TargetId } from "./types"; -import { didSucceed, useServerAction } from "~/lib/serverActions"; - -type RoleSelectProps = { - userId: UsersId; - targetId: T; - setRole: MembersListProps["setRole"]; - role: MemberRole; -}; - -export const RoleSelect = ({ - targetId, - role: currentRole, - userId, - setRole, -}: RoleSelectProps) => { - return ( - - - - - -
- {Object.values(MemberRole) - .filter((role) => role != currentRole) - .map((role) => { - return ( - - ); - })} -
-
-
- ); -}; - -const MenuButton = ({ - targetId, - role, - userId, - setRole, -}: RoleSelectProps) => { - const runSetRole = useServerAction(setRole); - - const handleClick = async () => { - const result = await runSetRole(targetId, role, userId); - if (didSucceed(result)) { - toast({ - title: "Success", - description: "User role updated", - }); - } - }; - - return ( - - - - ); -}; diff --git a/core/app/components/Memberships/actions.ts b/core/app/components/Memberships/actions.ts new file mode 100644 index 000000000..ba915c315 --- /dev/null +++ b/core/app/components/Memberships/actions.ts @@ -0,0 +1,195 @@ +"use server"; + +import type { User } from "lucia"; + +import type { CommunitiesId, FormsId, PubsId, StagesId, UsersId } from "db/public"; +import { Capabilities, MemberRole, MembershipType } from "db/public"; + +import type { CommunityData } from "~/lib/server/community"; +import { db } from "~/kysely/database"; +import { getLoginData } from "~/lib/authentication/loginData"; +import { isCommunityAdmin } from "~/lib/authentication/roles"; +import { userCan } from "~/lib/authorization/capabilities"; +import { findCommunityBySlug } from "~/lib/server/community"; +import { defineServerAction } from "~/lib/server/defineServerAction"; +import { + deleteCommunityMemberships, + deletePubMemberships, + deleteStageMemberships, + insertCommunityMemberships, + insertPubMemberships, + insertStageMemberships, +} from "~/lib/server/member"; + +async function userCanEditMember( + community: NonNullable, + user: User, + targetId: CommunitiesId | StagesId | PubsId, + targetType: MembershipType +) { + switch (targetType) { + case MembershipType.community: + return isCommunityAdmin(user, community); + case MembershipType.stage: + return userCan( + Capabilities.removeStageMember, + { type: MembershipType.stage, stageId: targetId as StagesId }, + user.id + ); + case MembershipType.pub: + return userCan( + Capabilities.removePubMember, + { type: MembershipType.pub, pubId: targetId as PubsId }, + user.id + ); + default: + return { + error: "Invalid membership type", + }; + } +} + +export const updateMember = defineServerAction(async function updateMember({ + userId, + role, + forms, + targetId, + targetType, +}: { + userId: UsersId; + role: MemberRole; + forms: FormsId[]; + targetId: CommunitiesId | StagesId | PubsId; + targetType: MembershipType; +}) { + try { + const [{ user }, community] = await Promise.all([getLoginData(), findCommunityBySlug()]); + + if (!community) { + return { + error: "Community not found", + }; + } + + if (!user) { + return { + error: "You are not logged in", + }; + } + + const userCanEditMemberResult = await userCanEditMember( + community, + user, + targetId, + targetType + ); + + if ( + userCanEditMemberResult === false || + (typeof userCanEditMemberResult === "object" && userCanEditMemberResult.error) + ) { + let target: string; + switch (targetType) { + case MembershipType.community: + target = "community"; + break; + case MembershipType.stage: + target = "stage"; + break; + case MembershipType.pub: + target = "pub"; + break; + default: + return { + title: "Failed to update member", + error: "Invalid membership type", + }; + } + return { + title: "Failed to update member", + error: `You do not have permission to edit members in this ${target}`, + }; + } + + const formsToInsert = role === MemberRole.contributor ? forms : []; + + const result = await db.transaction().execute(async (trx) => { + switch (targetType) { + case MembershipType.pub: + await deletePubMemberships( + { + pubId: targetId as PubsId, + userId, + }, + trx + ).execute(); + + return insertPubMemberships( + { + pubId: targetId as PubsId, + userId, + role, + forms: formsToInsert, + }, + trx + ).execute(); + case MembershipType.stage: + await deleteStageMemberships( + { + communityId: community.id, + userId, + }, + trx + ).execute(); + + return insertStageMemberships( + { + stageId: targetId as StagesId, + userId, + role, + forms: formsToInsert, + }, + trx + ).execute(); + case MembershipType.community: + await deleteCommunityMemberships( + { + communityId: community.id, + userId, + }, + trx + ).execute(); + + return insertCommunityMemberships( + { + communityId: community.id, + userId, + role, + forms: formsToInsert, + }, + trx + ).execute(); + default: + return { + title: "Failed to update member", + error: "An unexpected error occurred", + }; + } + }); + + if (!result) { + return { + title: "Failed to update member", + error: "An unexpected error occurred", + }; + } + + return { success: true }; + } catch (error) { + return { + title: "Failed to update member", + error: "An unexpected error occurred", + cause: error, + }; + } +}); diff --git a/core/app/components/Memberships/types.ts b/core/app/components/Memberships/types.ts index 56f53465a..de5760db4 100644 --- a/core/app/components/Memberships/types.ts +++ b/core/app/components/Memberships/types.ts @@ -1,4 +1,5 @@ import type { + CommunitiesId, FormsId, MemberRole, MembershipType, @@ -10,18 +11,13 @@ import type { import type { SafeUser } from "~/lib/server/user"; -export type TargetId = PubsId | StagesId; +export type TargetId = CommunitiesId | PubsId | StagesId; export type MembersListProps = { - members: (SafeUser & { role: MemberRole })[]; + members: (SafeUser & { role: MemberRole; formId: FormsId | null })[]; + membershipType: MembershipType; setRole: (targetId: T, role: MemberRole, userId: UsersId) => Promise; removeMember: (userId: UsersId, targetId: T) => Promise; - updateMember: (params: { - userId: UsersId; - role: MemberRole; - forms: FormsId[]; - targetId: T; - }) => Promise; readOnly: boolean; targetId: T; availableForms: { id: FormsId; name: string; isDefault: boolean }[]; @@ -53,21 +49,12 @@ export type DialogProps = { }; export type MemberEditDialogProps = { - // There's probably a better type for these functions that should be server actions - updateMember: ({ - userId, - role, - forms, - }: { - userId: UsersId; - role: MemberRole; - forms: FormsId[]; - }) => Promise; member: { userId: UsersId; role: MemberRole; forms: FormsId[]; }; + membershipTargetId: TargetId; membershipType: MembershipType; availableForms: { id: FormsId; name: string; isDefault: boolean }[]; minimal?: boolean; diff --git a/core/lib/db/queries.ts b/core/lib/db/queries.ts index 4463d2f2f..558eab7e1 100644 --- a/core/lib/db/queries.ts +++ b/core/lib/db/queries.ts @@ -4,7 +4,6 @@ import { jsonObjectFrom } from "kysely/helpers/postgres"; import type { ActionInstancesId, CommunitiesId, PubsId, StagesId, UsersId } from "db/public"; import { Event } from "db/public"; -import type { XOR } from "../types"; import type { RuleConfig } from "~/actions/types"; import { db } from "~/kysely/database"; import { pubType, pubValuesByRef } from "../server"; @@ -109,6 +108,7 @@ export const getStageMembers = cache((stageId: StagesId) => { .innerJoin("users", "users.id", "stage_memberships.userId") .select(SAFE_USER_SELECT) .select("stage_memberships.role") + .select("stage_memberships.formId") .orderBy("stage_memberships.createdAt asc") ); }); diff --git a/core/lib/server/member.ts b/core/lib/server/member.ts index 79af51260..6a66f965e 100644 --- a/core/lib/server/member.ts +++ b/core/lib/server/member.ts @@ -246,6 +246,26 @@ export const insertStageMemberships = ( trx = db ) => autoRevalidate(trx.insertInto("stage_memberships").values(getMembershipRows(membership))); +export const deleteStageMemberships = ( + params: { communityId: CommunitiesId; userId: UsersId }, + trx?: typeof db +) => { + const executor = trx ?? db; + return autoRevalidate( + executor + .deleteFrom("stage_memberships") + .where("stage_memberships.userId", "=", params.userId) + .where( + "stage_memberships.stageId", + "in", + executor + .selectFrom("stages") + .select("stages.id") + .where("stages.communityId", "=", params.communityId) + ) + ); +}; + export const insertStageMembershipsOverrideRole = ( props: NewStageMemberships & { userId: UsersId; forms: FormsId[] }, trx = db diff --git a/core/lib/server/pub.ts b/core/lib/server/pub.ts index 89f465034..14790b161 100644 --- a/core/lib/server/pub.ts +++ b/core/lib/server/pub.ts @@ -14,7 +14,6 @@ import partition from "lodash.partition"; import type { CreatePubRequestBodyWithNullsNew, Filter, - FTSReturn, Json, JsonValue, MaybePubOptions, @@ -1896,7 +1895,11 @@ export async function getPubsWithRelatedValues = Options["withPubType"] e * Only add the `members` if the `withMembers` option has not been set to `false` */ type MaybePubMembers = Options["withMembers"] extends true - ? { members: (Omit & { role: MemberRole })[] } + ? { members: (Omit & { role: MemberRole; formId: FormsId | null })[] } : Options["withMembers"] extends false ? { members?: never } - : { members?: (Omit & { role: MemberRole })[] }; + : { + members?: (Omit & { + role: MemberRole; + formId: FormsId | null; + })[]; + }; type MaybePubRelatedPub = Options["withRelatedPubs"] extends false ? { relatedPub?: never; relatedPubId: PubsId | null }