mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 04:15:30 +00:00
wip
This commit is contained in:
parent
06c84f0bf5
commit
8418f3645f
7 changed files with 86 additions and 276 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -144,7 +144,6 @@ export default async function MembersSettingsPage(props: MembersSettingsPageProp
|
|||
members={members}
|
||||
currentUserId={me.id}
|
||||
currentUserRole={userRoleInOrg}
|
||||
orgName={org.name}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
50
packages/web/src/features/members/actions.ts
Normal file
50
packages/web/src/features/members/actions.ts
Normal 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)
|
||||
));
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue