feat: Add ability to mark organization as default (#1991)
This commit is contained in:
Mauricio Siu
2025-11-02 22:33:38 -06:00
committed by GitHub
7 changed files with 6868 additions and 53 deletions

View File

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

View File

@@ -0,0 +1 @@
ALTER TABLE "member" ADD COLUMN "is_default" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -800,12 +800,12 @@
"tag": "0113_complete_rafael_vega", "tag": "0113_complete_rafael_vega",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 114, "idx": 114,
"version": "7", "version": "7",
"when": 1759643172958, "when": 1759643172958,
"tag": "0114_dry_black_tom", "tag": "0114_dry_black_tom",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 115, "idx": 115,
@@ -834,6 +834,13 @@
"when": 1761415824484, "when": 1761415824484,
"tag": "0118_loose_anita_blake", "tag": "0118_loose_anita_blake",
"breakpoints": true "breakpoints": true
},
{
"idx": 119,
"version": "7",
"when": 1762142756443,
"tag": "0119_bouncy_morbius",
"breakpoints": true
} }
] ]
} }

View File

@@ -41,6 +41,11 @@ export const organizationRouter = createTRPCRouter({
}); });
} }
// Check if this is the user's first organization
const existingMemberships = await db.query.member.findMany({
where: eq(member.userId, ctx.user.id),
});
await db.insert(member).values({ await db.insert(member).values({
organizationId: result.id, organizationId: result.id,
role: "owner", role: "owner",
@@ -63,6 +68,11 @@ export const organizationRouter = createTRPCRouter({
), ),
), ),
), ),
with: {
members: {
where: eq(member.userId, ctx.user.id),
},
},
}); });
return memberResult; return memberResult;
}), }),
@@ -184,4 +194,45 @@ export const organizationRouter = createTRPCRouter({
.delete(invitation) .delete(invitation)
.where(eq(invitation.id, input.invitationId)); .where(eq(invitation.id, input.invitationId));
}), }),
setDefault: protectedProcedure
.input(
z.object({
organizationId: z.string().min(1),
}),
)
.mutation(async ({ ctx, input }) => {
// Verify user is a member of this organization
const userMember = await db.query.member.findFirst({
where: and(
eq(member.organizationId, input.organizationId),
eq(member.userId, ctx.user.id),
),
});
if (!userMember) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not a member of this organization",
});
}
// First, unset all defaults for this user
await db
.update(member)
.set({ isDefault: false })
.where(eq(member.userId, ctx.user.id));
// Then set this organization as default
await db
.update(member)
.set({ isDefault: true })
.where(
and(
eq(member.organizationId, input.organizationId),
eq(member.userId, ctx.user.id),
),
);
return { success: true };
}),
}); });

View File

@@ -94,6 +94,7 @@ export const member = pgTable("member", {
role: text("role").notNull().$type<"owner" | "member" | "admin">(), role: text("role").notNull().$type<"owner" | "member" | "admin">(),
createdAt: timestamp("created_at").notNull(), createdAt: timestamp("created_at").notNull(),
teamId: text("team_id"), teamId: text("team_id"),
isDefault: boolean("is_default").notNull().default(false),
// Permissions // Permissions
canCreateProjects: boolean("canCreateProjects").notNull().default(false), canCreateProjects: boolean("canCreateProjects").notNull().default(false),
canAccessToSSHKeys: boolean("canAccessToSSHKeys").notNull().default(false), canAccessToSSHKeys: boolean("canAccessToSSHKeys").notNull().default(false),

View File

@@ -165,6 +165,7 @@ const { handler, api } = betterAuth({
organizationId: organization?.id || "", organizationId: organization?.id || "",
role: "owner", role: "owner",
createdAt: new Date(), createdAt: new Date(),
isDefault: true, // Mark first organization as default
}); });
}); });
} }
@@ -174,9 +175,14 @@ const { handler, api } = betterAuth({
session: { session: {
create: { create: {
before: async (session) => { before: async (session) => {
// Find the default organization for this user
// Priority: 1) isDefault=true, 2) most recently created
const member = await db.query.member.findFirst({ const member = await db.query.member.findFirst({
where: eq(schema.member.userId, session.userId), where: eq(schema.member.userId, session.userId),
orderBy: desc(schema.member.createdAt), orderBy: [
desc(schema.member.isDefault),
desc(schema.member.createdAt),
],
with: { with: {
organization: true, organization: true,
}, },