From 8418f3645fbf51e35aca20a3043923fee7bbaa0e Mon Sep 17 00:00:00 2001 From: bkellam Date: Wed, 12 Nov 2025 09:14:22 -0800 Subject: [PATCH] wip --- docs/docs/configuration/audit-logs.mdx | 2 - packages/web/src/actions.ts | 173 +----------------- .../members/components/membersList.tsx | 130 ++++--------- .../app/[domain]/settings/members/page.tsx | 1 - .../src/app/components/inviteLinkToggle.tsx | 2 +- packages/web/src/features/members/actions.ts | 50 +++++ packages/web/src/lib/posthogEvents.ts | 4 - 7 files changed, 86 insertions(+), 276 deletions(-) create mode 100644 packages/web/src/features/members/actions.ts diff --git a/docs/docs/configuration/audit-logs.mdx b/docs/docs/configuration/audit-logs.mdx index f229caaf..65f8e897 100644 --- a/docs/docs/configuration/audit-logs.mdx +++ b/docs/docs/configuration/audit-logs.mdx @@ -131,8 +131,6 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \ | `user.invite_accepted` | `user` | `invite` | | `user.signed_in` | `user` | `user` | | `user.signed_out` | `user` | `user` | -| `org.ownership_transfer_failed` | `user` | `org` | -| `org.ownership_transferred` | `user` | `org` | ## Response schema diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index e194f808..6a3896dd 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -22,7 +22,7 @@ import { createTransport } from "nodemailer"; import { Octokit } from "octokit"; import { auth } from "./auth"; import { getOrgFromDomain } from "./data/org"; -import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/billing/serverUtils"; +import { getSubscriptionForOrg } from "./ee/features/billing/serverUtils"; import { IS_BILLING_ENABLED } from "./ee/features/billing/stripe"; import InviteUserEmail from "./emails/inviteUserEmail"; import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail"; @@ -1139,102 +1139,6 @@ export const getInviteInfo = async (inviteId: string) => sew(() => } })); -export const transferOwnership = async (newOwnerId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth((userId) => - withOrgMembership(userId, domain, async ({ org }) => { - const currentUserId = userId; - - const failAuditCallback = async (error: string) => { - await auditService.createAudit({ - action: "org.ownership_transfer_failed", - actor: { - id: currentUserId, - type: "user" - }, - target: { - id: org.id.toString(), - type: "org" - }, - orgId: org.id, - metadata: { - message: error - } - }) - } - if (newOwnerId === currentUserId) { - await failAuditCallback("User is already the owner of this org"); - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: "You're already the owner of this org", - } satisfies ServiceError; - } - - const newOwner = await prisma.userToOrg.findUnique({ - where: { - orgId_userId: { - userId: newOwnerId, - orgId: org.id, - }, - }, - }); - - if (!newOwner) { - await failAuditCallback("The user you're trying to make the owner doesn't exist"); - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_REQUEST_BODY, - message: "The user you're trying to make the owner doesn't exist", - } satisfies ServiceError; - } - - await prisma.$transaction([ - prisma.userToOrg.update({ - where: { - orgId_userId: { - userId: newOwnerId, - orgId: org.id, - }, - }, - data: { - role: "OWNER", - } - }), - prisma.userToOrg.update({ - where: { - orgId_userId: { - userId: currentUserId, - orgId: org.id, - }, - }, - data: { - role: "MEMBER", - } - }) - ]); - - await auditService.createAudit({ - action: "org.ownership_transferred", - actor: { - id: currentUserId, - type: "user" - }, - target: { - id: org.id.toString(), - type: "org" - }, - orgId: org.id, - metadata: { - message: `Ownership transferred from ${currentUserId} to ${newOwnerId}` - } - }); - - return { - success: true, - } - }, /* minRequiredRole = */ OrgRole.OWNER) - )); - export const checkIfOrgDomainExists = async (domain: string): Promise => sew(() => withAuth(async () => { const org = await prisma.org.findFirst({ @@ -1246,81 +1150,6 @@ export const checkIfOrgDomainExists = async (domain: string): Promise => sew(() => - withAuth(async (userId) => - withOrgMembership(userId, domain, async ({ org }) => { - const targetMember = await prisma.userToOrg.findUnique({ - where: { - orgId_userId: { - orgId: org.id, - userId: memberId, - } - } - }); - - if (!targetMember) { - return notFound(); - } - - await prisma.$transaction(async (tx) => { - await tx.userToOrg.delete({ - where: { - orgId_userId: { - orgId: org.id, - userId: memberId, - } - } - }); - - if (IS_BILLING_ENABLED) { - const result = await decrementOrgSeatCount(org.id, tx); - if (isServiceError(result)) { - throw result; - } - } - }); - - return { - success: true, - } - }, /* minRequiredRole = */ OrgRole.OWNER) - )); - -export const leaveOrg = async (domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth(async (userId) => - withOrgMembership(userId, domain, async ({ org, userRole }) => { - if (userRole === OrgRole.OWNER) { - return { - statusCode: StatusCodes.FORBIDDEN, - errorCode: ErrorCode.OWNER_CANNOT_LEAVE_ORG, - message: "Organization owners cannot leave their own organization", - } satisfies ServiceError; - } - - await prisma.$transaction(async (tx) => { - await tx.userToOrg.delete({ - where: { - orgId_userId: { - orgId: org.id, - userId: userId, - } - } - }); - - if (IS_BILLING_ENABLED) { - const result = await decrementOrgSeatCount(org.id, tx); - if (isServiceError(result)) { - throw result; - } - } - }); - - return { - success: true, - } - }) - )); - export const getOrgMembers = async (domain: string) => sew(() => withAuth(async (userId) => withOrgMembership(userId, domain, async ({ org }) => { diff --git a/packages/web/src/app/[domain]/settings/members/components/membersList.tsx b/packages/web/src/app/[domain]/settings/members/components/membersList.tsx index e19860e5..79a3cc98 100644 --- a/packages/web/src/app/[domain]/settings/members/components/membersList.tsx +++ b/packages/web/src/app/[domain]/settings/members/components/membersList.tsx @@ -11,11 +11,11 @@ import { OrgRole } from "@prisma/client"; import placeholderAvatar from "@/public/placeholder_avatar.png"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; import { useDomain } from "@/hooks/useDomain"; -import { transferOwnership, removeMemberFromOrg, leaveOrg } from "@/actions"; import { isServiceError } from "@/lib/utils"; import { useToast } from "@/components/hooks/use-toast"; import { useRouter } from "next/navigation"; import useCaptureEvent from "@/hooks/useCaptureEvent"; +import { removeMemberFromOrg } from "@/features/members/actions"; type Member = { id: string @@ -30,20 +30,18 @@ export interface MembersListProps { members: Member[], currentUserId: string, currentUserRole: OrgRole, - orgName: string, } -export const MembersList = ({ members, currentUserId, currentUserRole, orgName }: MembersListProps) => { +export const MembersList = ({ members, currentUserId, currentUserRole }: MembersListProps) => { const [searchQuery, setSearchQuery] = useState("") const [roleFilter, setRoleFilter] = useState<"all" | OrgRole>("all") const [dateSort, setDateSort] = useState<"newest" | "oldest">("newest") const [memberToRemove, setMemberToRemove] = useState(null) - const [memberToTransfer, setMemberToTransfer] = useState(null) + const [roleChangeData, setRoleChangeData] = useState<{ member: Member; newRole: OrgRole } | null>(null) const domain = useDomain() const { toast } = useToast() const [isRemoveDialogOpen, setIsRemoveDialogOpen] = useState(false) - const [isTransferOwnershipDialogOpen, setIsTransferOwnershipDialogOpen] = useState(false) - const [isLeaveOrgDialogOpen, setIsLeaveOrgDialogOpen] = useState(false) + const [isRoleChangeDialogOpen, setIsRoleChangeDialogOpen] = useState(false) const router = useRouter(); const captureEvent = useCaptureEvent(); @@ -83,45 +81,9 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName } }); }, [domain, toast, router, captureEvent]); - const onTransferOwnership = useCallback((memberId: string) => { - transferOwnership(memberId, domain) - .then((response) => { - if (isServiceError(response)) { - toast({ - description: `❌ Failed to transfer ownership. Reason: ${response.message}` - }) - captureEvent('wa_members_list_transfer_ownership_fail', { - error: response.errorCode, - }) - } else { - toast({ - description: `✅ Ownership transferred successfully.` - }) - captureEvent('wa_members_list_transfer_ownership_success', {}) - router.refresh(); - } - }); - }, [domain, toast, router, captureEvent]); - - const onLeaveOrg = useCallback(() => { - leaveOrg(domain) - .then((response) => { - if (isServiceError(response)) { - toast({ - description: `❌ Failed to leave organization. Reason: ${response.message}` - }) - captureEvent('wa_members_list_leave_org_fail', { - error: response.errorCode, - }) - } else { - toast({ - description: `✅ You have left the organization.` - }) - captureEvent('wa_members_list_leave_org_success', {}) - router.push("/"); - } - }); - }, [domain, toast, router, captureEvent]); + const onChangeMembership = useCallback((_memberId: string, _newRole: OrgRole) => { + // @todo + }, []); return (
@@ -181,7 +143,25 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName }
- {member.role.toLowerCase()} +
@@ -273,46 +231,26 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName } - Transfer Ownership + Change Member Role - {`Are you sure you want to transfer ownership of ${orgName} to ${memberToTransfer?.name ?? memberToTransfer?.email}?`} + {roleChangeData && `Are you sure you want to change ${roleChangeData.member.name ?? roleChangeData.member.email}'s role to ${roleChangeData.newRole.toLowerCase()}?`} Cancel { - onTransferOwnership(memberToTransfer?.id ?? ""); + if (roleChangeData) { + onChangeMembership(roleChangeData.member.id, roleChangeData.newRole); + } }} > - Transfer - - - - - - - - Leave Organization - - {`Are you sure you want to leave ${orgName}?`} - - - - Cancel - - Leave + Confirm diff --git a/packages/web/src/app/[domain]/settings/members/page.tsx b/packages/web/src/app/[domain]/settings/members/page.tsx index cffedb9d..bb8b7621 100644 --- a/packages/web/src/app/[domain]/settings/members/page.tsx +++ b/packages/web/src/app/[domain]/settings/members/page.tsx @@ -144,7 +144,6 @@ export default async function MembersSettingsPage(props: MembersSettingsPageProp members={members} currentUserId={me.id} currentUserRole={userRoleInOrg} - orgName={org.name} /> diff --git a/packages/web/src/app/components/inviteLinkToggle.tsx b/packages/web/src/app/components/inviteLinkToggle.tsx index feaef814..6e7b21fd 100644 --- a/packages/web/src/app/components/inviteLinkToggle.tsx +++ b/packages/web/src/app/components/inviteLinkToggle.tsx @@ -121,7 +121,7 @@ export function InviteLinkToggle({ inviteLinkEnabled, inviteLink }: InviteLinkTo

- You can find this link again in the Settings → Members page. + You can find this link again in the Settings → Access page.

diff --git a/packages/web/src/features/members/actions.ts b/packages/web/src/features/members/actions.ts new file mode 100644 index 00000000..cb3d3681 --- /dev/null +++ b/packages/web/src/features/members/actions.ts @@ -0,0 +1,50 @@ +'use server'; + +import { sew, withAuth, withOrgMembership } from "@/actions"; +import { decrementOrgSeatCount } from "@/ee/features/billing/serverUtils"; +import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; +import { notFound, ServiceError } from "@/lib/serviceError"; +import { isServiceError } from "@/lib/utils"; +import { prisma } from "@/prisma"; +import { OrgRole } from "@sourcebot/db"; + + +export const removeMemberFromOrg = async (memberId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const targetMember = await prisma.userToOrg.findUnique({ + where: { + orgId_userId: { + orgId: org.id, + userId: memberId, + } + } + }); + + if (!targetMember) { + return notFound(); + } + + await prisma.$transaction(async (tx) => { + await tx.userToOrg.delete({ + where: { + orgId_userId: { + orgId: org.id, + userId: memberId, + } + } + }); + + if (IS_BILLING_ENABLED) { + const result = await decrementOrgSeatCount(org.id, tx); + if (isServiceError(result)) { + throw result; + } + } + }); + + return { + success: true, + } + }, /* minRequiredRole = */ OrgRole.OWNER) + )); \ No newline at end of file diff --git a/packages/web/src/lib/posthogEvents.ts b/packages/web/src/lib/posthogEvents.ts index 9ed40fd9..f56a688c 100644 --- a/packages/web/src/lib/posthogEvents.ts +++ b/packages/web/src/lib/posthogEvents.ts @@ -149,10 +149,6 @@ export type PosthogEventMap = { wa_members_list_remove_member_fail: { error: string, }, - wa_members_list_transfer_ownership_success: {}, - wa_members_list_transfer_ownership_fail: { - error: string, - }, wa_members_list_leave_org_success: {}, wa_members_list_leave_org_fail: { error: string,