Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions apps/web/actions/organization/remove-invite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

import { db } from "@cap/database";
import { getCurrentUser } from "@cap/database/auth/session";
import { organizationInvites, organizations } from "@cap/database/schema";
import {
organizationInvites,
organizationMembers,
organizations,
} from "@cap/database/schema";
import type { Organisation } from "@cap/web-domain";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
Expand All @@ -17,17 +21,29 @@ export async function removeOrganizationInvite(
throw new Error("Unauthorized");
}

const organization = await db()
.select()
const [organization] = await db()
.select({ id: organizations.id })
.from(organizations)
.where(eq(organizations.id, organizationId))
.limit(1);

if (!organization || organization.length === 0) {
if (!organization) {
throw new Error("Organization not found");
}

if (organization[0]?.ownerId !== user.id) {
const [ownerMembership] = await db()
.select({ id: organizationMembers.id })
.from(organizationMembers)
.where(
and(
eq(organizationMembers.organizationId, organizationId),
eq(organizationMembers.userId, user.id),
eq(organizationMembers.role, "owner"),
),
)
.limit(1);

if (!ownerMembership) {
throw new Error("Only the owner can remove organization invites");
}

Expand Down
35 changes: 20 additions & 15 deletions apps/web/actions/organization/remove-member.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,34 +6,40 @@ import { organizationMembers, organizations } from "@cap/database/schema";
import type { Organisation } from "@cap/web-domain";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";

/**
* Remove a member from an organization. Only the owner can perform this action.
* @param memberId The organizationMembers.id to remove
* @param organizationId The organization to remove from
*/
export async function removeOrganizationMember(
memberId: string,
organizationId: Organisation.OrganisationId,
) {
const user = await getCurrentUser();
if (!user) throw new Error("Unauthorized");

const organization = await db()
.select()
const [organization] = await db()
.select({ id: organizations.id })
.from(organizations)
.where(eq(organizations.id, organizationId))
.limit(1);

if (!organization || organization.length === 0) {
if (!organization) {
throw new Error("Organization not found");
}
if (organization[0]?.ownerId !== user.id) {

const [ownerMembership] = await db()
.select({ id: organizationMembers.id })
.from(organizationMembers)
.where(
and(
eq(organizationMembers.organizationId, organizationId),
eq(organizationMembers.userId, user.id),
eq(organizationMembers.role, "owner"),
),
)
.limit(1);

if (!ownerMembership) {
throw new Error("Only the owner can remove organization members");
}

// Prevent owner from removing themselves
const member = await db()
const [member] = await db()
.select()
.from(organizationMembers)
.where(
Expand All @@ -43,11 +49,10 @@ export async function removeOrganizationMember(
),
)
.limit(1);
if (!member || member.length === 0) {
if (!member) {
throw new Error("Member not found");
}
if (member[0]?.userId === user.id) {
// Defensive: this should never happen due to the above check, but TS wants safety
if (member.userId === user.id) {
throw new Error("Owner cannot remove themselves");
}

Expand Down
14 changes: 13 additions & 1 deletion apps/web/actions/organization/send-invites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,19 @@ export async function sendOrganizationInvites(
throw new Error("Organization not found");
}

if (organization.ownerId !== user.id) {
const [ownerMembership] = await db()
.select({ id: organizationMembers.id })
.from(organizationMembers)
.where(
and(
eq(organizationMembers.organizationId, organizationId),
eq(organizationMembers.userId, user.id),
eq(organizationMembers.role, "owner"),
),
)
.limit(1);

if (!ownerMembership) {
throw new Error("Only the organization owner can send invites");
}

Expand Down
16 changes: 13 additions & 3 deletions apps/web/app/(org)/dashboard/_components/Navbar/MemberAvatars.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,24 @@ export function MemberAvatars() {
const { activeOrganization, sidebarCollapsed, setInviteDialogOpen, user } =
useDashboardContext();

const isOwner = user?.id === activeOrganization?.organization.ownerId;
const userId = user?.id;
const isOwner =
userId != null &&
(activeOrganization?.members?.some(
(member) => member.userId === userId && member.role === "owner",
) ??
false);

if (sidebarCollapsed) return null;

const members = activeOrganization?.members ?? [];
const visibleMembers = members.slice(0, MAX_VISIBLE);
const extraCount = members.length - MAX_VISIBLE;
const emptySlots = Math.max(0, MAX_VISIBLE - members.length);
const emptySlotKeys = Array.from(
{ length: emptySlots },
(_, slotNumber) => `empty-${slotNumber + 1}`,
);

return (
<div className="flex items-center mt-2.5 px-2.5">
Expand Down Expand Up @@ -49,9 +59,9 @@ export function MemberAvatars() {
)}

{isOwner &&
Array.from({ length: emptySlots }).map((_, i) => (
emptySlotKeys.map((slotKey) => (
<Tooltip
key={`empty-${i}`}
key={slotKey}
content="Invite to your organization"
position="bottom"
delayDuration={0}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import { SeatManagementCard } from "../components/SeatManagementCard";
export default function BillingAndMembersPage() {
const { activeOrganization, user, setInviteDialogOpen } =
useDashboardContext();
const isOwner = user?.id === activeOrganization?.organization.ownerId;
const canManageMembers =
activeOrganization?.members?.some(
(member) => member.userId === user?.id && member.role === "owner",
) ?? false;
const ownerToastShown = useRef(false);

const showOwnerToast = useCallback(() => {
Expand All @@ -29,7 +32,7 @@ export default function BillingAndMembersPage() {
{buildEnv.NEXT_PUBLIC_IS_CAP && <BillingSummaryCard />}
{buildEnv.NEXT_PUBLIC_IS_CAP && <SeatManagementCard />}
<MembersCard
isOwner={isOwner}
canManageMembers={canManageMembers}
showOwnerToast={showOwnerToast}
setIsInviteDialogOpen={setInviteDialogOpen}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@ import { useDashboardContext } from "@/app/(org)/dashboard/Contexts";
import { calculateSeats } from "@/utils/organization";

interface MembersCardProps {
isOwner: boolean;
canManageMembers: boolean;
showOwnerToast: () => void;
setIsInviteDialogOpen: (isOpen: boolean) => void;
}

export const MembersCard = ({
isOwner,
canManageMembers,
showOwnerToast,
setIsInviteDialogOpen,
}: MembersCardProps) => {
Expand Down Expand Up @@ -139,9 +139,7 @@ export const MembersCard = ({
setConfirmOpen(true);
};

const isMemberOwner = (id: string) => {
return id === activeOrganization?.organization.ownerId;
};
const isMemberOwner = (role: string) => role === "owner";

return (
<>
Expand Down Expand Up @@ -179,13 +177,13 @@ export const MembersCard = ({
variant="dark"
className="px-6 min-w-auto"
onClick={() => {
if (!isOwner) {
if (!canManageMembers) {
showOwnerToast();
return;
}
setIsInviteDialogOpen(true);
}}
disabled={!isOwner}
disabled={!canManageMembers}
>
+ Invite users
</Button>
Expand All @@ -204,7 +202,7 @@ export const MembersCard = ({
</TableHeader>
<TableBody>
{activeOrganization?.members?.map((member) => {
const memberIsOwner = isMemberOwner(member.user.id);
const memberIsOwner = isMemberOwner(member.role);
return (
<TableRow key={member.id}>
<TableCell>{member.user.name}</TableCell>
Expand All @@ -224,7 +222,7 @@ export const MembersCard = ({
})
}
disabled={
!isOwner ||
!canManageMembers ||
(toggleProSeatMutation.isPending &&
toggleProSeatMutation.variables?.memberId ===
member.id) ||
Expand All @@ -246,7 +244,7 @@ export const MembersCard = ({
variant="destructive"
className="min-w-[unset] h-[28px]"
onClick={() => {
if (isOwner) {
if (canManageMembers) {
handleRemoveMember({
id: member.id,
user: {
Expand All @@ -258,7 +256,7 @@ export const MembersCard = ({
showOwnerToast();
}
}}
disabled={!isOwner}
disabled={!canManageMembers}
>
Remove
</Button>
Expand All @@ -283,13 +281,15 @@ export const MembersCard = ({
size="xs"
variant="destructive"
onClick={() => {
if (isOwner) {
if (canManageMembers) {
deleteInviteMutation.mutate(invite.id);
} else {
showOwnerToast();
}
}}
disabled={!isOwner || deletingInviteId === invite.id}
disabled={
!canManageMembers || deletingInviteId === invite.id
}
>
{deletingInviteId === invite.id
? "Deleting..."
Expand Down