List actions
List actions are bulk verbs on selected rows. Every list has a built-in Delete selected action when the current admin has delete permission. It is the default action, honours scope, runs the model's delete hooks, and writes a delete audit event.
You can add custom actions for other operations, such as publishing posts:
import { prismaActionWhere } from "@paneljs/prisma";
admin.register("Post", {
actions: [
{
name: "publish_selected",
label: "Publish selected posts",
allowedRoles: ["SUPER_ADMIN", "ADMIN"],
handler: async ({ client, where }) => {
const result = await client.post.updateMany({
where: prismaActionWhere("id", where),
data: { published: true },
});
return { message: `Published ${result.count} posts.` };
},
},
],
});import { typeormActionWhere } from "@paneljs/typeorm";
import type { DataSource } from "typeorm";
admin.register("Post", {
actions: [
{
name: "publish_selected",
label: "Publish selected posts",
allowedRoles: ["SUPER_ADMIN", "ADMIN"],
handler: async ({ client, where }) => {
const result = await (client as DataSource)
.getRepository("Post")
.update(typeormActionWhere("id", where), { published: true });
const count = result.affected ?? 0;
return { message: `Published ${count} posts.` };
},
},
],
});import { mikroormActionWhere } from "@paneljs/mikroorm";
import type { MikroORM } from "@mikro-orm/core";
admin.register("Post", {
actions: [
{
name: "publish_selected",
label: "Publish selected posts",
allowedRoles: ["SUPER_ADMIN", "ADMIN"],
handler: async ({ client, where }) => {
const orm = client as MikroORM;
const count = await orm.em
.fork()
.nativeUpdate("Post", mikroormActionWhere(orm, "Post", where), {
published: true,
});
return { message: `Published ${count} posts.` };
},
},
],
});The UI shows label. The route is POST /admin/api/posts/actions/publish_selected.
What the handler receives
{
ids: Array<string | number>; // only rows that passed scope
adminUser: AdminUser;
client: unknown; // Prisma client, TypeORM DataSource, or MikroORM instance
where: {
scope: Record<string, unknown>;
ids: Array<string | number>;
}
}Return { message: string }. That string is what the UI toasts.
Safety checks, in order
- Caller is authenticated
- Caller has list permission on the model
- The action exists and the caller may run it (
allowedRoles, orpermissions.actions[name]) - Body is
{ ids: [...] }— 1 to 100 unique string/number ids - Those rows are reloaded with the action
where(scope + selected ids) - If any id is missing, the action does not run (
400) - Handler, then optional audit
{ type: "action", metadata: { action } }
Ada cannot publish Grace’s draft by pasting its id into the request.
Permissions
allowedRoles on the action is the usual allowlist. Super-admin bypasses it. An action must define allowedRoles, permissions.actions[name], or both; omitting both denies the action.
You can also set permissions.actions.publish_selected. Both are enforced when present.
The schema endpoint only lists actions this person may run. Hidden in the UI is not the boundary — the POST is.
The built-in delete action uses the model's delete permission; it cannot be changed through permissions.actions.
Do the work safely
Use where.scope and ids (or where.ids) in every mutation so the action stays tenant-safe. where is an adapter-neutral target, not an ORM-native query object.
- Prisma:
prismaActionWhere("id", where)from@paneljs/prisma - TypeORM:
typeormActionWhere("id", where)from@paneljs/typeorm - MikroORM:
mikroormActionWhere(orm, "Post", where)from@paneljs/mikroorm