mirror of
https://github.com/andwati/dokploy.git
synced 2026-11-03 18:52:17 +00:00
feat(organization): allow setting a default role for new members
This commit is contained in:
@@ -106,6 +106,7 @@ export const AddInvitation = () => {
|
|||||||
const { mutateAsync: createUserWithCredentials, isPending: isCreating } =
|
const { mutateAsync: createUserWithCredentials, isPending: isCreating } =
|
||||||
api.user.createUserWithCredentials.useMutation();
|
api.user.createUserWithCredentials.useMutation();
|
||||||
const { data: customRoles } = api.customRole.all.useQuery();
|
const { data: customRoles } = api.customRole.all.useQuery();
|
||||||
|
const { data: activeOrganization } = api.organization.active.useQuery();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const form = useForm<AddInvitation>({
|
const form = useForm<AddInvitation>({
|
||||||
@@ -132,6 +133,16 @@ export const AddInvitation = () => {
|
|||||||
}
|
}
|
||||||
}, [form, isCloud]);
|
}, [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) => {
|
const onSubmit = async (data: AddInvitation) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
@@ -267,10 +278,7 @@ export const AddInvitation = () => {
|
|||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Role</FormLabel>
|
<FormLabel>Role</FormLabel>
|
||||||
<Select
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value}
|
|
||||||
>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a role" />
|
<SelectValue placeholder="Select a role" />
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { api } from "@/utils/api";
|
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 CustomRolesContent = () => {
|
||||||
const {
|
const {
|
||||||
data: customRoles,
|
data: customRoles,
|
||||||
@@ -821,6 +900,9 @@ const CustomRolesContent = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<DefaultRoleSection
|
||||||
|
customRoles={customRoles?.map((role) => role.role) ?? []}
|
||||||
|
/>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<HandleCustomRole onSuccess={refetch} />
|
<HandleCustomRole onSuccess={refetch} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "organization" ADD COLUMN "default_role" text;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1275,6 +1275,13 @@
|
|||||||
"when": 1786400712839,
|
"when": 1786400712839,
|
||||||
"tag": "0181_dashing_havok",
|
"tag": "0181_dashing_havok",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 182,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1786435879438,
|
||||||
|
"tag": "0182_skinny_wild_pack",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { db } from "@dokploy/server/db";
|
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 { TRPCError } from "@trpc/server";
|
||||||
import { and, desc, eq, exists } from "drizzle-orm";
|
import { and, desc, eq, exists } from "drizzle-orm";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
@@ -128,6 +132,7 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
logo: z.string().optional(),
|
logo: z.string().optional(),
|
||||||
|
defaultRole: z.string().min(1).nullable().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.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
|
const result = await db
|
||||||
.update(organization)
|
.update(organization)
|
||||||
.set({
|
.set({
|
||||||
name: input.name,
|
name: input.name,
|
||||||
logo: input.logo,
|
logo: input.logo,
|
||||||
|
...(input.defaultRole !== undefined && {
|
||||||
|
defaultRole: input.defaultRole,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
.where(eq(organization.id, input.organizationId))
|
.where(eq(organization.id, input.organizationId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { db } from "@dokploy/server/db";
|
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 { statements } from "@dokploy/server/lib/access-control";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { and, count, eq } from "drizzle-orm";
|
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, {
|
await audit(ctx, {
|
||||||
action: "delete",
|
action: "delete",
|
||||||
resourceType: "customRole",
|
resourceType: "customRole",
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export const organization = pgTable("organization", {
|
|||||||
logo: text("logo"),
|
logo: text("logo"),
|
||||||
createdAt: timestamp("created_at").notNull(),
|
createdAt: timestamp("created_at").notNull(),
|
||||||
metadata: text("metadata"),
|
metadata: text("metadata"),
|
||||||
|
defaultRole: text("default_role"),
|
||||||
ownerId: text("owner_id")
|
ownerId: text("owner_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
getUserByToken,
|
getUserByToken,
|
||||||
} from "../services/admin";
|
} from "../services/admin";
|
||||||
import { createAuditLog } from "../services/proprietary/audit-log";
|
import { createAuditLog } from "../services/proprietary/audit-log";
|
||||||
|
import { resolveOrganizationDefaultRole } from "../services/proprietary/license-key";
|
||||||
import {
|
import {
|
||||||
getWebServerSettings,
|
getWebServerSettings,
|
||||||
updateWebServerSettings,
|
updateWebServerSettings,
|
||||||
@@ -289,10 +290,13 @@ const createBetterAuth = () =>
|
|||||||
message: "Provider not found",
|
message: "Provider not found",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const defaultRole = provider.organizationId
|
||||||
|
? await resolveOrganizationDefaultRole(provider.organizationId)
|
||||||
|
: "member";
|
||||||
await db.insert(schema.member).values({
|
await db.insert(schema.member).values({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
organizationId: provider?.organizationId || "",
|
organizationId: provider?.organizationId || "",
|
||||||
role: "member",
|
role: defaultRole,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
import { user } from "@dokploy/server/db/schema";
|
import {
|
||||||
import { eq } from "drizzle-orm";
|
organization,
|
||||||
|
organizationRole,
|
||||||
|
user,
|
||||||
|
} from "@dokploy/server/db/schema";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
import { getOrganizationOwnerId } from "./sso";
|
import { getOrganizationOwnerId } from "./sso";
|
||||||
|
|
||||||
export const hasValidLicense = async (organizationId: string) => {
|
export const hasValidLicense = async (organizationId: string) => {
|
||||||
@@ -22,3 +26,35 @@ export const hasValidLicense = async (organizationId: string) => {
|
|||||||
currentUser?.isValidEnterpriseLicense
|
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;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user