feat(organization): allow setting a default role for new members

This commit is contained in:
Mauricio Siu 2026-08-11 02:21:33 -06:00
parent 1ccf15f331
commit aaf8d7e12f
10 changed files with 9241 additions and 9 deletions

View File

@ -106,6 +106,7 @@ export const AddInvitation = () => {
const { mutateAsync: createUserWithCredentials, isPending: isCreating } =
api.user.createUserWithCredentials.useMutation();
const { data: customRoles } = api.customRole.all.useQuery();
const { data: activeOrganization } = api.organization.active.useQuery();
const [error, setError] = useState<string | null>(null);
const form = useForm<AddInvitation>({
@ -132,6 +133,16 @@ export const AddInvitation = () => {
}
}, [form, isCloud]);
useEffect(() => {
if (
activeOrganization?.defaultRole &&
activeOrganization.defaultRole !== "owner" &&
!form.formState.dirtyFields.role
) {
form.setValue("role", activeOrganization.defaultRole);
}
}, [form, activeOrganization?.defaultRole]);
const onSubmit = async (data: AddInvitation) => {
setError(null);
@ -267,10 +278,7 @@ export const AddInvitation = () => {
return (
<FormItem>
<FormLabel>Role</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a role" />

View File

@ -45,6 +45,13 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
@ -780,6 +787,78 @@ function HandleCustomRole({
);
}
const DefaultRoleSection = ({ customRoles }: { customRoles: string[] }) => {
const utils = api.useUtils();
const { data: auth } = api.user.get.useQuery();
const { data: activeOrganization } = api.organization.active.useQuery();
const { mutateAsync: updateOrganization, isPending: isUpdating } =
api.organization.update.useMutation();
const [selectedRole, setSelectedRole] = useState<string>();
if (auth?.role !== "owner" || !activeOrganization) {
return null;
}
const currentRole = activeOrganization.defaultRole ?? "member";
const value = selectedRole ?? currentRole;
const onSave = async () => {
await updateOrganization({
organizationId: activeOrganization.id,
name: activeOrganization.name,
logo: activeOrganization.logo ?? undefined,
defaultRole: value,
})
.then(() => {
toast.success("Default role updated");
utils.organization.active.invalidate();
})
.catch((error) => {
toast.error(
error instanceof Error
? error.message
: "Error updating default role",
);
});
};
return (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 rounded-lg border bg-muted/20 p-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">Default role for new members</p>
<p className="text-xs text-muted-foreground">
Assigned automatically to users joining through SSO and preselected
when creating invitations.
</p>
</div>
<div className="flex items-center gap-2">
<Select value={value} onValueChange={setSelectedRole}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
{customRoles.map((role) => (
<SelectItem key={role} value={role}>
{role}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
isLoading={isUpdating}
disabled={value === currentRole}
onClick={onSave}
>
Save
</Button>
</div>
</div>
);
};
const CustomRolesContent = () => {
const {
data: customRoles,
@ -821,6 +900,9 @@ const CustomRolesContent = () => {
return (
<div className="space-y-4">
<DefaultRoleSection
customRoles={customRoles?.map((role) => role.role) ?? []}
/>
<div className="flex justify-end">
<HandleCustomRole onSuccess={refetch} />
</div>

View File

@ -0,0 +1 @@
ALTER TABLE "organization" ADD COLUMN "default_role" text;

File diff suppressed because it is too large Load Diff

View File

@ -1275,6 +1275,13 @@
"when": 1786400712839,
"tag": "0181_dashing_havok",
"breakpoints": true
},
{
"idx": 182,
"version": "7",
"when": 1786435879438,
"tag": "0182_skinny_wild_pack",
"breakpoints": true
}
]
}

View File

@ -1,5 +1,9 @@
import { db } from "@dokploy/server/db";
import { IS_CLOUD, sendInvitationEmail } from "@dokploy/server/index";
import {
hasValidLicense,
IS_CLOUD,
sendInvitationEmail,
} from "@dokploy/server/index";
import { TRPCError } from "@trpc/server";
import { and, desc, eq, exists } from "drizzle-orm";
import { nanoid } from "nanoid";
@ -128,6 +132,7 @@ export const organizationRouter = createTRPCRouter({
organizationId: z.string(),
name: z.string(),
logo: z.string().optional(),
defaultRole: z.string().min(1).nullable().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
@ -170,11 +175,47 @@ export const organizationRouter = createTRPCRouter({
});
}
if (input.defaultRole !== undefined && input.defaultRole !== null) {
if (input.defaultRole === "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Cannot set owner as the default role",
});
}
if (!["admin", "member"].includes(input.defaultRole)) {
const customRole = await db.query.organizationRole.findFirst({
where: and(
eq(organizationRole.organizationId, input.organizationId),
eq(organizationRole.role, input.defaultRole),
),
});
if (!customRole) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Role "${input.defaultRole}" not found`,
});
}
if (!(await hasValidLicense(input.organizationId))) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Setting a custom role as default requires a valid enterprise license",
});
}
}
}
const result = await db
.update(organization)
.set({
name: input.name,
logo: input.logo,
...(input.defaultRole !== undefined && {
defaultRole: input.defaultRole,
}),
})
.where(eq(organization.id, input.organizationId))
.returning();

View File

@ -1,5 +1,10 @@
import { db } from "@dokploy/server/db";
import { member, organizationRole, user } from "@dokploy/server/db/schema";
import {
member,
organization,
organizationRole,
user,
} from "@dokploy/server/db/schema";
import { statements } from "@dokploy/server/lib/access-control";
import { TRPCError } from "@trpc/server";
import { and, count, eq } from "drizzle-orm";
@ -255,6 +260,16 @@ export const customRoleRouter = createTRPCRouter({
});
}
await db
.update(organization)
.set({ defaultRole: null })
.where(
and(
eq(organization.id, ctx.session.activeOrganizationId),
eq(organization.defaultRole, input.roleName),
),
);
await audit(ctx, {
action: "delete",
resourceType: "customRole",

View File

@ -66,6 +66,7 @@ export const organization = pgTable("organization", {
logo: text("logo"),
createdAt: timestamp("created_at").notNull(),
metadata: text("metadata"),
defaultRole: text("default_role"),
ownerId: text("owner_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),

View File

@ -18,6 +18,7 @@ import {
getUserByToken,
} from "../services/admin";
import { createAuditLog } from "../services/proprietary/audit-log";
import { resolveOrganizationDefaultRole } from "../services/proprietary/license-key";
import {
getWebServerSettings,
updateWebServerSettings,
@ -289,10 +290,13 @@ const createBetterAuth = () =>
message: "Provider not found",
});
}
const defaultRole = provider.organizationId
? await resolveOrganizationDefaultRole(provider.organizationId)
: "member";
await db.insert(schema.member).values({
userId: user.id,
organizationId: provider?.organizationId || "",
role: "member",
role: defaultRole,
createdAt: new Date(),
isDefault: true,
});

View File

@ -1,6 +1,10 @@
import { db } from "@dokploy/server/db";
import { user } from "@dokploy/server/db/schema";
import { eq } from "drizzle-orm";
import {
organization,
organizationRole,
user,
} from "@dokploy/server/db/schema";
import { and, eq } from "drizzle-orm";
import { getOrganizationOwnerId } from "./sso";
export const hasValidLicense = async (organizationId: string) => {
@ -22,3 +26,35 @@ export const hasValidLicense = async (organizationId: string) => {
currentUser?.isValidEnterpriseLicense
);
};
export const resolveOrganizationDefaultRole = async (
organizationId: string,
) => {
const org = await db.query.organization.findFirst({
where: eq(organization.id, organizationId),
columns: { defaultRole: true },
});
const defaultRole = org?.defaultRole;
if (!defaultRole || defaultRole === "owner") {
return "member";
}
if (defaultRole === "admin" || defaultRole === "member") {
return defaultRole;
}
const customRole = await db.query.organizationRole.findFirst({
where: and(
eq(organizationRole.organizationId, organizationId),
eq(organizationRole.role, defaultRole),
),
columns: { id: true },
});
if (!customRole || !(await hasValidLicense(organizationId))) {
return "member";
}
return defaultRole;
};