Refactor organization management in Sidebar: streamline organization selection and default setting logic. Update API to return organization memberships with default status. Improve UI for organization actions in the sidebar.

This commit is contained in:
Mauricio Siu
2025-11-02 22:27:04 -06:00
parent 1dc5bbd9bd
commit a475361b80
2 changed files with 119 additions and 188 deletions

View File

@@ -498,7 +498,6 @@ function SidebarLogo() {
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: user } = api.user.get.useQuery();
const { data: session } = authClient.useSession();
const {
data: organizations,
refetch,
@@ -597,118 +596,124 @@ function SidebarLogo() {
<DropdownMenuLabel className="text-xs text-muted-foreground">
Organizations
</DropdownMenuLabel>
{organizations?.map((org) => (
<div className="flex flex-row justify-between" key={org.name}>
<DropdownMenuItem
onClick={async () => {
await authClient.organization.setActive({
organizationId: org.id,
});
window.location.reload();
}}
className="w-full gap-2 p-2"
{organizations?.map((org) => {
const isDefault = org.members?.[0]?.isDefault ?? false;
return (
<div
className="flex flex-row justify-between"
key={org.name}
>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
{org.name}
<DropdownMenuItem
onClick={async () => {
await authClient.organization.setActive({
organizationId: org.id,
});
window.location.reload();
}}
className="w-full gap-2 p-2"
>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
{org.name}
</div>
</div>
</div>
<div className="flex size-6 items-center justify-center rounded-sm border">
<Logo
className={cn(
"transition-all",
state === "collapsed" ? "size-6" : "size-10",
)}
logoUrl={org.logo ?? undefined}
/>
</div>
</DropdownMenuItem>
{org.ownerId === session?.user?.id && (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className={cn(
"group",
org.isDefault
? "hover:bg-yellow-500/10"
: "hover:bg-blue-500/10",
)}
isLoading={isSettingDefault && !org.isDefault}
disabled={org.isDefault}
onClick={async (e) => {
if (org.isDefault) return;
e.stopPropagation();
await setDefaultOrganization({
organizationId: org.id,
})
.then(() => {
refetch();
toast.success("Default organization updated");
})
.catch((error) => {
toast.error(
error?.message ||
"Error setting default organization",
);
});
}}
title={
org.isDefault
? "Default organization"
: "Set as default"
}
>
{org.isDefault ? (
<Star
fill="#eab308"
stroke="#eab308"
className="size-4 text-yellow-500"
/>
) : (
<Star
fill="none"
stroke="currentColor"
className="size-4 text-gray-400 group-hover:text-blue-500 transition-colors"
/>
)}
</Button>
<AddOrganization organizationId={org.id} />
<DialogAction
title="Delete Organization"
description="Are you sure you want to delete this organization?"
type="destructive"
onClick={async () => {
await deleteOrganization({
organizationId: org.id,
})
.then(() => {
refetch();
toast.success(
"Organization deleted successfully",
);
})
.catch((error) => {
toast.error(
error?.message ||
"Error deleting organization",
);
});
}}
>
<div className="flex size-6 items-center justify-center rounded-sm border">
<Logo
className={cn(
"transition-all",
state === "collapsed" ? "size-6" : "size-10",
)}
logoUrl={org.logo ?? undefined}
/>
</div>
</DropdownMenuItem>
{org.ownerId === session?.user?.id && (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
className={cn(
"group",
isDefault
? "hover:bg-yellow-500/10"
: "hover:bg-blue-500/10",
)}
isLoading={isSettingDefault && !isDefault}
disabled={isDefault}
onClick={async (e) => {
if (isDefault) return;
e.stopPropagation();
await setDefaultOrganization({
organizationId: org.id,
})
.then(() => {
refetch();
toast.success("Default organization updated");
})
.catch((error) => {
toast.error(
error?.message ||
"Error setting default organization",
);
});
}}
title={
isDefault
? "Default organization"
: "Set as default"
}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
{isDefault ? (
<Star
fill="#eab308"
stroke="#eab308"
className="size-4 text-yellow-500"
/>
) : (
<Star
fill="none"
stroke="currentColor"
className="size-4 text-gray-400 group-hover:text-blue-500 transition-colors"
/>
)}
</Button>
</DialogAction>
</div>
)}
</div>
))}
<AddOrganization organizationId={org.id} />
<DialogAction
title="Delete Organization"
description="Are you sure you want to delete this organization?"
type="destructive"
onClick={async () => {
await deleteOrganization({
organizationId: org.id,
})
.then(() => {
refetch();
toast.success(
"Organization deleted successfully",
);
})
.catch((error) => {
toast.error(
error?.message ||
"Error deleting organization",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
)}
</div>
);
})}
{(user?.role === "owner" || isCloud) && (
<>
<DropdownMenuSeparator />

View File

@@ -46,85 +46,16 @@ export const organizationRouter = createTRPCRouter({
where: eq(member.userId, ctx.user.id),
});
const isFirstOrganization = existingMemberships.length === 0;
await db.insert(member).values({
organizationId: result.id,
role: "owner",
createdAt: new Date(),
userId: ctx.user.id,
isDefault: isFirstOrganization, // Mark as default if it's the first organization
});
return result;
}),
all: protectedProcedure.query(async ({ ctx }) => {
// Get all memberships for the user with organization info
// Query memberships first to get the isDefault value correctly
const memberships = await db
.select({
organizationId: member.organizationId,
isDefault: member.isDefault,
createdAt: member.createdAt,
})
.from(member)
.where(eq(member.userId, ctx.user.id));
// If no default is set, set the oldest organization as default
const hasDefault = memberships.some((m) => m.isDefault === true);
if (!hasDefault && memberships.length > 0) {
// Find the oldest membership (first created)
const oldestMembership = memberships.reduce((oldest, current) =>
current.createdAt < oldest.createdAt ? current : oldest,
);
// Set it as default
await db
.update(member)
.set({ isDefault: true })
.where(
and(
eq(member.organizationId, oldestMembership.organizationId),
eq(member.userId, ctx.user.id),
),
);
// Update the memberships array
const updatedMemberships = memberships.map((m) =>
m.organizationId === oldestMembership.organizationId
? { ...m, isDefault: true }
: m,
);
// Get all organizations for the user
const organizations = await db.query.organization.findMany({
where: (organization) =>
exists(
db
.select()
.from(member)
.where(
and(
eq(member.organizationId, organization.id),
eq(member.userId, ctx.user.id),
),
),
),
});
// Create a map of organizationId to isDefault
const defaultMap = new Map(
updatedMemberships.map((m) => [m.organizationId, Boolean(m.isDefault)]),
);
// Map organizations with their isDefault flag
return organizations.map((org) => ({
...org,
isDefault: defaultMap.get(org.id) ?? false,
}));
}
// Get all organizations for the user
const organizations = await db.query.organization.findMany({
const memberResult = await db.query.organization.findMany({
where: (organization) =>
exists(
db
@@ -137,18 +68,13 @@ export const organizationRouter = createTRPCRouter({
),
),
),
with: {
members: {
where: eq(member.userId, ctx.user.id),
},
},
});
// Create a map of organizationId to isDefault
const defaultMap = new Map(
memberships.map((m) => [m.organizationId, Boolean(m.isDefault)]),
);
// Map organizations with their isDefault flag
return organizations.map((org) => ({
...org,
isDefault: defaultMap.get(org.id) ?? false,
}));
return memberResult;
}),
one: protectedProcedure
.input(
@@ -271,7 +197,7 @@ export const organizationRouter = createTRPCRouter({
setDefault: protectedProcedure
.input(
z.object({
organizationId: z.string(),
organizationId: z.string().min(1),
}),
)
.mutation(async ({ ctx, input }) => {