feat: Add default organization selection (#1991)

This commit is contained in:
HarikrishnanD
2025-10-31 20:21:49 +05:30
parent dadef000d5
commit a14cc09933
7 changed files with 216 additions and 7 deletions

View File

@@ -26,6 +26,7 @@ import {
PieChart,
Server,
ShieldCheck,
Star,
Trash2,
User,
Users,
@@ -505,6 +506,8 @@ function SidebarLogo() {
} = api.organization.all.useQuery();
const { mutateAsync: deleteOrganization, isLoading: isRemoving } =
api.organization.delete.useMutation();
const { mutateAsync: setDefaultOrganization, isLoading: isSettingDefault } =
api.organization.setDefault.useMutation();
const { isMobile } = useSidebar();
const { data: activeOrganization } = authClient.useActiveOrganization();
const _utils = api.useUtils();
@@ -605,7 +608,11 @@ function SidebarLogo() {
}}
className="w-full gap-2 p-2"
>
<div className="flex flex-col gap-4">{org.name}</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
{org.name}
</div>
</div>
<div className="flex size-6 items-center justify-center rounded-sm border">
<Logo
className={cn(
@@ -618,6 +625,52 @@ function SidebarLogo() {
</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"

View File

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

View File

@@ -1,5 +1,5 @@
{
"id": "70e92aa8-56d1-4842-b844-775cb8198849",
"id": "c615c052-2494-4e3c-9fc2-14f26fc65160",
"prevId": "8310936d-e9ff-408b-ae6d-2292aac02523",
"version": "7",
"dialect": "postgresql",
@@ -421,6 +421,13 @@
"primaryKey": false,
"notNull": false
},
"is_default": {
"name": "is_default",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"canCreateProjects": {
"name": "canCreateProjects",
"type": "boolean",

View File

@@ -834,6 +834,13 @@
"when": 1761415824484,
"tag": "0118_loose_anita_blake",
"breakpoints": true
},
{
"idx": 119,
"version": "7",
"when": 1761920677980,
"tag": "0114_sudden_sheva_callister",
"breakpoints": true
}
]
}

View File

@@ -41,16 +41,90 @@ 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),
});
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 }) => {
const memberResult = await db.query.organization.findMany({
// 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({
where: (organization) =>
exists(
db
@@ -64,7 +138,17 @@ export const organizationRouter = createTRPCRouter({
),
),
});
return memberResult;
// 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,
}));
}),
one: protectedProcedure
.input(
@@ -184,4 +268,45 @@ export const organizationRouter = createTRPCRouter({
.delete(invitation)
.where(eq(invitation.id, input.invitationId));
}),
setDefault: protectedProcedure
.input(
z.object({
organizationId: z.string(),
}),
)
.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">(),
createdAt: timestamp("created_at").notNull(),
teamId: text("team_id"),
isDefault: boolean("is_default").notNull().default(false),
// Permissions
canCreateProjects: boolean("canCreateProjects").notNull().default(false),
canAccessToSSHKeys: boolean("canAccessToSSHKeys").notNull().default(false),

View File

@@ -165,6 +165,7 @@ const { handler, api } = betterAuth({
organizationId: organization?.id || "",
role: "owner",
createdAt: new Date(),
isDefault: true, // Mark first organization as default
});
});
}
@@ -174,14 +175,28 @@ const { handler, api } = betterAuth({
session: {
create: {
before: async (session) => {
const member = await db.query.member.findFirst({
where: eq(schema.member.userId, session.userId),
orderBy: desc(schema.member.createdAt),
// First try to find the default organization for this user
let member = await db.query.member.findFirst({
where: and(
eq(schema.member.userId, session.userId),
eq(schema.member.isDefault, true),
),
with: {
organization: true,
},
});
// If no default is set, fallback to the most recently created organization
if (!member) {
member = await db.query.member.findFirst({
where: eq(schema.member.userId, session.userId),
orderBy: desc(schema.member.createdAt),
with: {
organization: true,
},
});
}
return {
data: {
...session,