This commit is contained in:
bkellam 2025-11-12 09:14:22 -08:00
parent 06c84f0bf5
commit 8418f3645f
7 changed files with 86 additions and 276 deletions

View file

@ -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

View file

@ -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<boolean | ServiceError> => sew(() =>
withAuth(async () => {
const org = await prisma.org.findFirst({
@ -1246,81 +1150,6 @@ export const checkIfOrgDomainExists = async (domain: string): Promise<boolean |
return !!org;
}));
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)
));
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 }) => {

View file

@ -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<Member | null>(null)
const [memberToTransfer, setMemberToTransfer] = useState<Member | null>(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 (
<div>
@ -181,7 +143,25 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName }
</div>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground capitalize">{member.role.toLowerCase()}</span>
<Select
value={member.role}
onValueChange={(value) => {
const newRole = value as OrgRole;
if (newRole !== member.role) {
setRoleChangeData({ member, newRole });
setIsRoleChangeDialogOpen(true);
}
}}
disabled={member.id === currentUserId || currentUserRole !== OrgRole.OWNER}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder={member.role.toLowerCase()} />
</SelectTrigger>
<SelectContent>
<SelectItem value={OrgRole.OWNER}>Owner</SelectItem>
<SelectItem value={OrgRole.MEMBER}>Member</SelectItem>
</SelectContent>
</Select>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
@ -207,17 +187,6 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName }
>
Copy email
</DropdownMenuItem>
{member.id !== currentUserId && currentUserRole === OrgRole.OWNER && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
setMemberToTransfer(member);
setIsTransferOwnershipDialogOpen(true);
}}
>
Transfer ownership
</DropdownMenuItem>
)}
{member.id !== currentUserId && currentUserRole === OrgRole.OWNER && (
<DropdownMenuItem
className="cursor-pointer text-destructive"
@ -229,17 +198,6 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName }
Remove
</DropdownMenuItem>
)}
{member.id === currentUserId && (
<DropdownMenuItem
className="cursor-pointer text-destructive"
disabled={currentUserRole === OrgRole.OWNER}
onClick={() => {
setIsLeaveOrgDialogOpen(true);
}}
>
Leave organization
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
@ -273,46 +231,26 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName }
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={isTransferOwnershipDialogOpen}
onOpenChange={setIsTransferOwnershipDialogOpen}
open={isRoleChangeDialogOpen}
onOpenChange={setIsRoleChangeDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Transfer Ownership</AlertDialogTitle>
<AlertDialogTitle>Change Member Role</AlertDialogTitle>
<AlertDialogDescription>
{`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()}?`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
onTransferOwnership(memberToTransfer?.id ?? "");
if (roleChangeData) {
onChangeMembership(roleChangeData.member.id, roleChangeData.newRole);
}
}}
>
Transfer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={isLeaveOrgDialogOpen}
onOpenChange={setIsLeaveOrgDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Leave Organization</AlertDialogTitle>
<AlertDialogDescription>
{`Are you sure you want to leave ${orgName}?`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={onLeaveOrg}
>
Leave
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View file

@ -144,7 +144,6 @@ export default async function MembersSettingsPage(props: MembersSettingsPageProp
members={members}
currentUserId={me.id}
currentUserRole={userRoleInOrg}
orgName={org.name}
/>
</TabsContent>

View file

@ -121,7 +121,7 @@ export function InviteLinkToggle({ inviteLinkEnabled, inviteLink }: InviteLinkTo
</div>
<p className="text-sm text-[var(--muted-foreground)]">
You can find this link again in the <strong>Settings Members</strong> page.
You can find this link again in the <strong>Settings Access</strong> page.
</p>
</div>
</div>

View file

@ -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)
));

View file

@ -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,