feat(permissions): add AI module to enterprise custom roles

This commit is contained in:
Yash Kumar
2026-09-20 22:57:01 +05:30
parent e0006758c0
commit b5473e63fd
5 changed files with 89 additions and 47 deletions
@@ -18,7 +18,9 @@ import { HandleAiProviders } from "./handle-ai-providers";
export const AiForm = () => {
const { data: aiConfigs, refetch, isPending } = api.ai.getAll.useQuery();
const { mutateAsync, isPending: isRemoving } = api.ai.delete.useMutation();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: currentUser } = api.user.get.useQuery();
const isOrgAdmin =
currentUser?.role === "owner" || currentUser?.role === "admin";
@@ -36,7 +38,9 @@ export const AiForm = () => {
</div>
<div className="flex flex-row gap-2">
{isOrgAdmin && <HandleAiProviders />}
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
{permissions?.ai.create && aiConfigs && aiConfigs?.length > 0 && (
<HandleAi />
)}
</div>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
@@ -53,7 +57,7 @@ export const AiForm = () => {
<span className="text-base text-muted-foreground text-center">
You don't have any AI configurations
</span>
<HandleAi />
{permissions?.ai.create && <HandleAi />}
</div>
) : (
<div className="flex flex-col gap-4 rounded-lg min-h-[25vh]">
@@ -70,33 +74,37 @@ export const AiForm = () => {
<CardDescription>{config.model}</CardDescription>
</div>
<div className="flex justify-between items-center">
<HandleAi aiId={config.aiId} />
<DialogAction
title="Delete AI"
description="Are you sure you want to delete this AI?"
type="destructive"
onClick={async () => {
await mutateAsync({
aiId: config.aiId,
})
.then(() => {
toast.success("AI deleted successfully");
refetch();
{permissions?.ai.update && (
<HandleAi aiId={config.aiId} />
)}
{permissions?.ai.delete && (
<DialogAction
title="Delete AI"
description="Are you sure you want to delete this AI?"
type="destructive"
onClick={async () => {
await mutateAsync({
aiId: config.aiId,
})
.catch(() => {
toast.error("Error deleting AI");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
.then(() => {
toast.success("AI deleted successfully");
refetch();
})
.catch(() => {
toast.error("Error deleting AI");
});
}}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
<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>
+1 -1
View File
@@ -351,7 +351,7 @@ const MENU: Menu = {
icon: BotIcon,
url: "/dashboard/settings/ai",
isSingle: true,
isEnabled: ({ permissions }) => !!permissions?.organization.update,
isEnabled: ({ permissions }) => !!permissions?.ai.read,
},
{
isSingle: true,
@@ -185,6 +185,11 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
description:
"Manage the providers (Grafana Loki, Prometheus, Datadog, New Relic, etc.) that container logs and metrics are shipped to",
},
ai: {
label: "AI Settings",
description:
"Manage AI providers, models, and use AI features (log analysis, suggestions)",
},
};
/** Descriptions for each action within a resource */
@@ -495,6 +500,25 @@ const ACTION_META: Record<
description: "Remove logs and metrics providers",
},
},
ai: {
read: {
label: "Read",
description:
"View AI providers and use AI features (log analysis, suggestions)",
},
create: {
label: "Create",
description: "Add new AI provider configurations",
},
update: {
label: "Update",
description: "Edit AI provider settings and custom providers",
},
delete: {
label: "Delete",
description: "Remove AI provider configurations",
},
},
};
/** Resources that should be hidden from the custom role editor (better-auth internals) */
@@ -606,6 +630,7 @@ const ROLE_PRESETS: {
logs: ["read"],
monitoring: ["read"],
auditLog: ["read"],
ai: ["read", "create", "update", "delete"],
},
},
];
+22 -18
View File
@@ -38,18 +38,18 @@ import { slugify } from "@/lib/slug";
import {
adminProcedure,
createTRPCRouter,
protectedProcedure,
withPermission,
} from "@/server/api/trpc";
import { generatePassword } from "@/templates/utils";
export const aiRouter = createTRPCRouter({
one: adminProcedure
one: withPermission("ai", "read")
.input(z.object({ aiId: z.string() }))
.query(async ({ input }) => {
return await getAiSettingById(input.aiId);
}),
getModels: protectedProcedure
getModels: withPermission("ai", "read")
.input(z.object({ apiUrl: z.string().min(1), apiKey: z.string() }))
.query(async ({ input }) => {
try {
@@ -177,33 +177,37 @@ export const aiRouter = createTRPCRouter({
});
}
}),
create: adminProcedure.input(apiCreateAi).mutation(async ({ ctx, input }) => {
return await saveAiSettings(ctx.session.activeOrganizationId, input);
}),
create: withPermission("ai", "create")
.input(apiCreateAi)
.mutation(async ({ ctx, input }) => {
return await saveAiSettings(ctx.session.activeOrganizationId, input);
}),
update: adminProcedure.input(apiUpdateAi).mutation(async ({ ctx, input }) => {
return await saveAiSettings(ctx.session.activeOrganizationId, input);
}),
update: withPermission("ai", "update")
.input(apiUpdateAi)
.mutation(async ({ ctx, input }) => {
return await saveAiSettings(ctx.session.activeOrganizationId, input);
}),
getAll: adminProcedure.query(async ({ ctx }) => {
getAll: withPermission("ai", "read").query(async ({ ctx }) => {
return await getAiSettingsByOrganizationId(
ctx.session.activeOrganizationId,
);
}),
get: adminProcedure
get: withPermission("ai", "read")
.input(z.object({ aiId: z.string() }))
.query(async ({ input }) => {
return await getAiSettingById(input.aiId);
}),
delete: adminProcedure
delete: withPermission("ai", "delete")
.input(z.object({ aiId: z.string() }))
.mutation(async ({ input }) => {
return await deleteAiSettings(input.aiId);
}),
getCustomProviders: protectedProcedure.query(async ({ ctx }) => {
getCustomProviders: withPermission("ai", "read").query(async ({ ctx }) => {
return await getCustomAiProviders(ctx.session.activeOrganizationId);
}),
@@ -216,7 +220,7 @@ export const aiRouter = createTRPCRouter({
);
}),
getEnabledProviders: protectedProcedure.query(async ({ ctx }) => {
getEnabledProviders: withPermission("ai", "read").query(async ({ ctx }) => {
const settings = await getAiSettingsByOrganizationId(
ctx.session.activeOrganizationId,
);
@@ -225,7 +229,7 @@ export const aiRouter = createTRPCRouter({
.map((s) => ({ aiId: s.aiId, name: s.name, model: s.model }));
}),
analyzeLogs: protectedProcedure
analyzeLogs: withPermission("ai", "read")
.input(
z.object({
aiId: z.string().min(1),
@@ -283,7 +287,7 @@ ${input.logs}`,
}
}),
testConnection: protectedProcedure
testConnection: withPermission("ai", "create")
.input(
z.object({
apiUrl: z.string().min(1),
@@ -317,7 +321,7 @@ ${input.logs}`,
}
}),
suggest: protectedProcedure
suggest: withPermission("ai", "read")
.input(
z.object({
aiId: z.string(),
@@ -338,7 +342,7 @@ ${input.logs}`,
});
}
}),
deploy: protectedProcedure
deploy: withPermission("ai", "read")
.input(deploySuggestionSchema)
.mutation(async ({ ctx, input }) => {
const environment = await findEnvironmentById(input.environmentId);
+6 -1
View File
@@ -9,7 +9,7 @@ import { createAccessControl } from "better-auth/plugins/access";
* The rest are Dokploy-specific resources.
*
* Enterprise-only resources (only assignable via custom roles):
* deployment, envVars, server, registry, certificate, backup, domain, logs, monitoring
* deployment, envVars, server, registry, certificate, backup, domain, logs, monitoring, ai
*/
export const statements = {
// better-auth organization plugin defaults
@@ -51,6 +51,7 @@ export const statements = {
auditLog: ["read"],
vaultProvider: ["read", "create", "update", "delete"],
dnsProvider: ["read", "create", "update", "delete"],
ai: ["read", "create", "update", "delete"],
} as const;
/**
@@ -79,6 +80,7 @@ export const enterpriseOnlyResources = new Set<string>([
"auditLog",
"vaultProvider",
"dnsProvider",
"ai",
]);
export const ac = createAccessControl(statements);
@@ -121,6 +123,7 @@ export const ownerRole = ac.newRole({
vaultProvider: ["read", "create", "update", "delete"],
dnsProvider: ["read", "create", "update", "delete"],
telemetryProvider: ["read", "create", "delete"],
ai: ["read", "create", "update", "delete"],
});
/**
@@ -161,6 +164,7 @@ export const adminRole = ac.newRole({
vaultProvider: ["read", "create", "update", "delete"],
dnsProvider: ["read", "create", "update", "delete"],
telemetryProvider: ["read", "create", "delete"],
ai: ["read", "create", "update", "delete"],
});
/**
@@ -207,4 +211,5 @@ export const memberRole = ac.newRole({
// Members need provider/secret names for env editor autocomplete; values are never exposed
vaultProvider: ["read"],
dnsProvider: [],
ai: [],
});