@kamalyb/mongoose-explore
v2.4.0
Published
an **opinionated** and highly customizable ~~admin interface~~ interface generated using [pug](https://pugjs.org/api/getting-started.html) to _explore_ your mongoose models. strongly inspired by [adminjs](https://adminjs.co)
Downloads
246
Maintainers
Readme
mongoose explore
an opinionated and highly customizable ~~admin interface~~ interface generated using pug to explore your mongoose models. strongly inspired by adminjs
table of contents
- mongoose explore
installation
npm install @kamalyb/mongoose-explore
# or
yarn add @kamalyb/mongoose-explore
# or
pnpm add @kamalyb/mongoose-explore
prerequisites
- do not disable mongoose's schema types casting as
Model.castObject()
is used to accurately convert url-encoded data to align with the corresponding model's schema when creating and editing documents - if your application utilizes
helmet
middleware, allow theunsafe-inline
script source directive in your content security policy
app.use(
helmet({
contentSecurityPolicy: {
directives: {
"script-src": ["'unsafe-inline'", "https://cdn.jsdelivr.net"],
"script-src-attr": ["'unsafe-inline'"]
}
}
})
);
quick start
import express from "express";
import mongoose from "mongoose";
import { MongooseExplorer } from "@kamalyb/mongoose-explore";
const app = express();
const explorer = new MongooseExplorer({ mongoose });
app.use(explorer.rootpath, explorer.router());
await mongoose.connect();
app.listen(port);
voila! you now have an ~~admin interface~~ interface on /admin/explorer to explore and manipulate your mongoose models
configuration options
the MongooseExplorer
constructor accepts a configuration object with the
following options:
mongoose
(required)
the mongoose instance to use for database operations
rootpath
- type:
string
- default:
/admin/explorer
- description: the base URL path where the explorer will be mounted
const explorer = new MongooseExplorer({
mongoose,
rootpath: "/custom-admin" // access via http://your-app.com/custom-admin
});
css
- type:
string
- description: specifies global or custom styles for the explorer. this can be an absolute path to a css file or a string containing inline css. the styles are applied globally or can be used for custom pages and HTML
const explorer = new MongooseExplorer({
mongoose,
css: `
.custom-class {
color: red;
}
`,
dashboard: {
render: () => {
return `
<div>
<p class="custom-class">hello world</p>
</div>
`;
}
}
});
datetime_formatter
- type:
(date: Date) => string
- default: 24-hour format with short month (e.g., "Mar 22, 2024, 13:45")
- description: formatted string representation of dates
// default implementation
const explorer = new MongooseExplorer({
mongoose,
datetime_formatter: (date) =>
date.toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false
})
});
explorables
- type:
string[]
- description: list of model names to expose in the explorer. if not
provided, all models will be available. if set, only the resources
included in it are available, rendering any
explorable: true
setting under individual resource configuration irrelevant. supports exclusions by prefixing model names with a-
sign
const explorer = new MongooseExplorer({
mongoose,
explorables: ["User", "Post", "Comment"] // only these models will be accessible
});
const explorer = new MongooseExplorer({
mongoose,
explorables: ["-Password"] // all models will be accessible except Password
});
query_runner
- type:
boolean | string[]
- default:
false
- description: enable custom query execution and instantly view the result.
can be limited to specific models. it supports
find
,findOne
,aggregate
,countDocuments
, andestimatedDocumentCount
mongoose operations. query options are applicable tofind
andfindOne
operations only
// enable for all models
const explorer = new MongooseExplorer({
mongoose,
query_runner: true
});
// enable only for specific models
const explorer = new MongooseExplorer({
mongoose,
query_runner: ["User", "Post"]
});
fallback_value
- type:
string
- default:
-
- description: string or html content to display when a property has no value (null or undefined)
const explorer = new MongooseExplorer({
mongoose,
// plain text
fallback_value: "N/A",
// or html for styled content
fallback_value: "<span style="color: orange">no data</span>"
});
timestamps
- type:
Timestamps
- default:
{ created: "created_at", updated: "updated_at" }
- description: maps your mongoose schema's timestamp field names to prevent their modification in the interface among other things
Timestamps
definition
interface Timestamps {
created?: string;
updated?: string;
}
const explorer = new MongooseExplorer({
mongoose,
timestamps: {
created: "insertedAt", // if your schema uses insertedAt
updated: "modifiedAt" // if your schema uses modifiedAt
}
});
titles
- type:
Titles
- default:
'explorer'
- description: customize html page titles for different views and operations in the interface
Titles
definition
interface Titles {
dashboard?: string;
list?: (modelname: string) => string;
view?: (modelname: string, doc: object) => string;
edit?: (modelname: string, doc: object) => string;
create?: (modelname: string) => string;
query_runner?: (modelname: string) => string;
}
const explorer = new MongooseExplorer({
mongoose,
titles: {
// static title for dashboard
dashboard: "Admin Dashboard",
// dynamic titles using model names and documents
list: (modelname) => `${modelname} List`,
view: (modelname, doc) => `${doc.name} - ${modelname} Details`,
edit: (modelname, doc) => `Edit ${modelname}: ${doc.name}`,
create: (modelname) => `New ${modelname}`,
query_runner: (modelname) => `Query ${modelname}`
}
});
examples of generated titles:
// for a User model with document { name: "John Doe" }
{
dashboard: "Admin Dashboard";
list: "User List";
view: "John Doe - User Details";
edit: "Edit User: John Doe";
create: "New User";
query_runner: "Query User";
}
theming
- type:
ThemingOptions
- description: customize the visual appearance of the interface
ThemingOptions
definition
type Theme = DeepRequired<ThemingOptions>;
interface ThemingOptions {
colors?: Colors;
font?: Font;
}
interface Colors {
primary?: string;
secondary?: string;
danger?: string;
warning?: string;
success?: string;
border?: string;
text?: string;
custom?: Record<string, `#${string}`>;
}
interface Font {
url: string;
family: string;
}
type Color =
| "primary"
| "secondary"
| "success"
| "danger"
| "warning"
| "dark"
| "gray"
| "white"
| (string & {}); // allows custom color strings
type Variant = "filled" | "outline" | "light" | "subtle";
type Size =
| "xs"
| "compact-xs"
| "sm"
| "compact-sm"
| "md"
| "compact-md"
| "lg"
| "compact-lg"
| "xl"
| "compact-xl";
color customization
- all color inputs generate CSS variables and classes
- creates
var(--color-{name})
and.color-{name}
- background fixed at
#101113
(dark shade, non-customizable) - custom colors can be added through
custom
object
default theme
- predefined theme with Finlandica font
- default color palette for the interface
{
font: {
family: `"Finlandica", sans-serif`,
url: "https://fonts.googleapis.com/css2?family=Finlandica&display=swap"
},
colors: {
primary: "#4f46e5",
secondary: "#DCE546",
danger: "#dc2626",
success: "#059669",
warning: "#d97706",
border: "#4B5563",
text: "#fff"
}
};
css variables
the following CSS variables are available throughout the interface:
/* typography */
--font-family
--font-size
/* colors */
--color-primary
--color-secondary
--color-success
--color-danger
--color-warning
--color-dark
--color-gray
--color-{custom-name} /* generated for each custom color */
/* spacing */
--spacing-xs
--spacing-sm
--spacing-md
--spacing-lg
--spacing-xl
/* border radius */
--radius-xs
--radius-sm
--radius-md
--radius-lg
--radius-xl
css classes
button classes
the .button
class must be combined with variant, color, and size modifiers
for styling:
/* base class (no styling by itself) */
.button
/* size modifiers */
.button.size-xs
.button.size-sm
.button.size-md
.button.size-lg
.button.size-xl
/* compact variants */
.button.size-compact-xs /* smaller version of xs */
.button.size-compact-sm /* smaller version of sm */
.button.size-compact-md /* smaller version of md */
.button.size-compact-lg /* smaller version of lg */
.button.size-compact-xl /* smaller version of xl */
/* variant modifiers */
.button.variant-filled
.button.variant-light
.button.variant-outline
.button.variant-subtle
/* color modifiers - generated for all predefined and custom colors */
.button.color-primary
.button.color-secondary
.button.color-success
/* etc... */
form element classes
/* input */
.input
.input.size-xs
.input.size-sm
.input.size-md
.input.size-lg
.input.size-xl
/* textarea */
.textarea
.textarea.size-xs
.textarea.size-sm
.textarea.size-md
.textarea.size-lg
.textarea.size-xl
/* select */
.select
.select.size-xs
.select.size-sm
.select.size-md
.select.size-lg
.select.size-xl
configuration example
- customize theme colors and font
- add custom colors with
custom
property - override default theme settings
const explorer = new MongooseExplorer({
mongoose,
theming: {
colors: {
primary: "#4f46e5",
custom: {
coral: "#FF7F50"
}
},
font: {
family: '"Custom Font", sans-serif',
url: "https://fonts.googleapis.com/css2?family=CustomFont&display=swap"
}
}
});
// button html example
<button class="button color-primary variant-filled size-compact-sm">
Click me
</button>
// input html example
<input class="input size-md" type="text">
version_key
- type:
string
- default:
__v
- description: the version key of your schema, if enabled
show_indexes
- type:
boolean
- default:
false
- description: controls visibility of model indexes in the interface
bulk_delete
- type:
boolean
- default:
false
- description: enable or disable bulk deletion of documents
dashboard
- type:
DashboardOptions
- description: customize the dashboard page content and styling. by default, the dashboard is empty. any html content returned by the render function will be wrapped in the interface layout to maintain consistent navigation
DashboardOptions
definition
interface DashboardOptions {
/**
* function to generate dashboard's html content
*/
render?: (req: Request, theme: Theme) => string | Promise<string>;
/**
* additional html to inject into the <head> section.
* useful for including external resources like stylesheets, remote scripts, etc
*/
head?: string;
/**
* javascript for the dashboard page.
* can either be an absolute file path (e.g., "C:/users/john/project/dashboard.js")
* or inline javascript as a string
*/
js?: string;
/**
* css styles for the dashboard page.
* can either be an absolute file path (e.g., "C:/users/john/project/dashboard.css")
* or inline css as a string
*/
css?: string;
/**
* custom css styles for different dashboard components.
* all style properties accept css rules as strings
*/
styles?: {
/**
* style container that holds all dashboard widgets
*/
widgets?: string;
/**
* style main wrapper containing both rendered html and widgets container
*/
wrapper?: string;
/**
* style statistical widget containers (displays single value metrics)
*/
stat?: string;
/**
* style pie chart widget containers
*/
piechart?: string;
/**
* style bar chart widget containers
*/
barchart?: string;
/**
* style tabular data widget containers
*/
tabular?: string;
/**
* style doughnut chart widget containers
*/
doughnutchart?: string;
/**
* style line chart widget containers
*/
linechart?: string;
/**
* style radar chart widget containers
*/
radarchart?: string;
};
}
example usage
const explorer = new MongooseExplorer({
mongoose,
dashboard: {
render: async (req, theme) => {
const count = await User.countDocuments();
return `
<div class="dashboard-stats">
<div class="stat-card">
<h3>Total Users</h3>
<p>${count}</p>
</div>
</div>
`;
},
// include external resources
head: `
<link rel="stylesheet" href="https://cdn.example.com/custom-styles.css">
<script src="https://cdn.example.com/animation-library.js"></script>
`,
// custom javascript (using absolute path)
js: "C:/users/john/project/dashboard.js",
// or inline javascript
js: `
// add custom event handlers
document.querySelector(".stat-card).addEventListener("click", () => {
//
});
`,
css: `
.stat-card {
border-radius: var(--radius-md);
box-shadow: rgba(0, 0, 0, 0.16) 0px 1px 4px;
}
`,
// custom styles for different components
styles: {
// style the wrapper containing everything
wrapper: `
padding: 2rem;
background: #f5f5f5;
display: grid;
gap: 1rem;
`,
// style the widgets container
widgets: `
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
padding: 1rem;
`,
// style stat widget containers
stat: `
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
`,
// style chart widget containers
piechart: `
background: white;
padding: 1rem;
border-radius: 8px;
min-height: 300px;
`
}
}
});
widgets
- type:
Widget[]
- description: An array of widgets to display on the dashboard. each widget can be of different types (stat, bar chart, pie chart, tabular, doughnut chart, line chart, radar chart, custom)
Widget
definition
type Widget =
| StatWidget
| BarChartWidget
| PieChartWidget
| TabularWidget
| DoughnutWidget
| LineWidget
| RadarWidget
| CustomWidget;
interface StatWidget {
type: "stat";
key?: string;
title: string;
resolver: () => Promise<string | number>;
/**
* custom rendering function for the displayed value.
* can return a plain string or html string to be rendered
*/
render?: (value: string | number, theme: Theme) => string;
}
interface BarChartWidget {
type: "bar chart";
key?: string;
title: string;
resolver: () => Promise<{
labels: string[];
datasets: {
label: string;
data: number[];
}[];
}>;
}
interface PieChartWidget {
type: "pie chart";
key?: string;
title: string;
resolver: () => Promise<{
labels: string[];
data: number[];
}>;
}
interface TabularWidget {
type: "tabular";
key?: string;
title: string;
resolver: () => Promise<object[]>;
/**
* determines whether table header is displayed
*/
header?: boolean;
}
interface DoughnutWidget {
type: "doughnut chart";
key?: string;
title: string;
resolver: () => Promise<{
labels: string[];
data: number[];
}>;
}
interface LineWidget {
type: "line chart";
key?: string;
title: string;
resolver: () => Promise<{
labels: string[];
datasets: {
label: string;
data: number[];
}[];
}>;
}
interface RadarWidget {
type: "radar chart";
key?: string;
title: string;
resolver: () => Promise<{
labels: string[];
datasets: {
label: string;
data: number[];
}[];
}>;
}
interface CustomWidget {
type: "custom";
key?: string;
title: string;
render: (theme: Theme) => string | Promise<string>;
element?: {
style?: string;
};
}
note on key
: explicitly for access control. acts as
a unique identifier for the widget. if key
is absent, title
will be used
as a fallback for compatibility. however, using unique key
values is
highly recommended to avoid ambiguity
example usage
const explorer = new MongooseExplorer({
mongoose,
widgets: [
{
type: "stat",
title: "Total Users",
resolver: async () => {
return await User.countDocuments();
},
render: (value, theme) => `
<div style="text-align: center">
<h3 style="color: ${theme.colors.primary}">${value}</h3>
<p>registered users</p>
</div>
`
},
{
type: "bar chart",
title: "Monthly Signups",
resolver: async () => {
const data = await User.aggregate([
{
$group: {
_id: { $month: "$created_at" },
count: { $sum: 1 }
}
}
]);
return {
labels: ["Jan", "Feb", "Mar", "Apr", "May", "Jun"],
datasets: [
{
label: "New Users",
data: data.map((d) => d.count)
}
]
};
}
},
{
type: "custom",
title: "Custom Content",
render: () => `
<div style="text-align: center; padding: 1rem;">
<h3>Custom Widget</h3>
<p>This is a fully custom widget!</p>
</div>
`,
element: {
style:
"background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);"
}
}
]
});
resources
- type:
ResourcesOptions
- default:
{}
- description: configures how each mongoose model is handled in the explorer
ResourcesOptions
definition
type ResourcesOptions = Record<string, ResourceOptions>;
interface ResourceOptions {
/**
* whether the resource is explorable
*
* @default true
*/
explorable?: boolean;
/**
* allow creating new documents
*
* @default true
*/
creatable?: boolean;
/**
* allow document deletion
*
* @default true
*/
deletable?: boolean | ((doc: object) => boolean);
/**
* allow document editing
*
* @default true
*/
editable?: boolean | ((doc: object) => boolean);
/**
* allow sorting
*
* @default true
*/
sortable?: boolean;
/**
* number of documents displayed per page.
* can be changed from the interface
*/
limit?: number;
/**
* properties which are shown in the interface.
* this takes precedence over the `viewable` option at the property level
*/
viewables?: string[];
/**
* properties which can be used as a filtering criteria.
* this takes precedence over the `filterable` option at the property level
*/
filterables?: string[];
/**
* properties which can be created.
* this takes precedence over the `creatable` option at the property level
*/
creatables?: string[];
/**
* properties which can be edited.
* this takes precedence over the `editable` option at the property level
*/
editables?: string[];
/**
* properties which can be sorted.
* this takes precedence over the `sortable` option at the property level
*/
sortables?: string[];
/**
* open referenced documents in new tab
*
* @default false
*/
ref_newtab?: boolean;
/**
* custom computed fields that appear in the interface
*/
virtuals?: Record<string, (doc: object) => string>;
/**
* display model indexes
*
* @default false
*/
show_indexes?: boolean;
/**
* enable custom query execution and instantly view the result
*/
query_runner?: {
/**
* this takes precedence over the option `query_runner` option at the explorer level
*
* @default false
*/
enabled?: boolean;
/**
* predefined queries that can be run with a single click.
* the concept is similar to PostgreSQL views where you dont have to type the query each time you need it
*/
presets?: {
name: string;
resolver: () => Promise<any>;
}[];
};
bulk_delete?: {
/**
* enables bulk deletion of documents.
* this takes precedence over `bulk_delete` option at the explorer level
*
* @default false
*/
enabled?: boolean;
/**
* determines how deletion is performed.
* documents are deleted using `Document.deleteOne()` if true, otherwise the
* default behavior is `Model.deleteMany()`
*
* @default false
*/
use_document?: boolean;
};
/**
* cascade delete behavior for dependent documents.
* if a function is passed, the session is defined if mongodb is in a replica set.
* uses transaction to delete documents if mongodb is in a replica set or `Promise.all` otherwise.
* currently not supported for bulk delete
*/
cascade_delete?:
| {
/**
* related model to delete from
*/
model: mongoose.Model;
relation: {
/**
* field in the current model
*/
local_field: string;
/**
* field in the related model
*/
foreign_field: string;
};
}[]
| ((
doc: mongoose.Document,
session?: mongodb.ClientSession
) => Promise<void>);
/**
* document operations
*/
actions?: Actions;
/**
* individual property configuration
*/
properties?: Record<string, PropertyOptions>;
/**
* access control settings for the resource
* see [resource-level access control](#resource-level-access-control)
*/
access_control?: ResourceAccessControlPolicy;
}
interface Actions {
custom?: CustomActions;
/**
* override default document creation (new Model().save())
*/
create?: (body: AnyObject) => Promise<mongoose.Document>;
/**
* override default deletion (Document.deleteOne())
*/
delete?(doc: mongoose.Document): Promise<mongoose.Document>;
/**
* override default update (Document.set().save({ validateModifiedOnly: true, timestamps }))
*/
update?(
doc: mongoose.Document,
body: AnyObject,
timestamps?: boolean
): Promise<mongoose.Document>;
}
interface CustomActions {
/**
* actions that can be performed on multiple selected documents
*/
bulk?: {
/**
* must be unique
*/
operation: string;
/**
* operation implementation
*/
execute: (ids: string[]) => void | Promise<void>;
/**
* window.confirm prompt message before executing handler.
* if true default message "are you sure?" is used, otherwise no prompt
* and handler is executed immediately
*
* @default false
*/
guard?: boolean | string;
element?: {
/**
* visual variant for the button, `filled`, `outline`, `light`, or `subtle`
*
* @default outline
*/
variant?: Variant;
/**
* color of the button. can be one of the predefined colors (primary, secondary, success,
* danger, warning, dark, gray, white) or any custom color defined in theming.colors.custom
*
* @default white
*/
color?: Color;
/**
* size of the button, `xs`, `compact-xs`, `sm`, `compact-sm`, `md`, `compact-md`, `lg`,
* `compact-lg`, `xl` and `compact-xl`
*
* @default compact-sm
*/
size?: Size;
/**
* label of the operation's button
*
* @default ${operation}
*/
label?: string;
/**
* extra styling for the operation's button
*/
style?: string;
};
}[];
/**
* actions that can be performed on individual documents
*/
document?: {
/**
* must be unique
*/
operation: string;
execute: (doc: mongoose.Document) => void | Promise<void>;
/**
* determines whether the action is applicable to the document
*/
applicable?: (doc: object) => boolean;
guard?: boolean | string;
element?: {
variant?: Variant;
color?: Color;
size?: Size;
label?: string;
style?: string;
};
}[];
}
interface PropertyOptions {
/**
* custom display label for the field in the interface
*/
label?: string;
/**
* controls whether this field can be modified
*/
editable?: boolean;
/**
* enables sorting by this field
*/
sortable?: boolean;
/**
* controls field visibility in the interface
*/
viewable?: boolean;
/**
* determines whether this field can be used as a filtering criteria
*/
filterable?: boolean;
/**
* determines if this field is required when creating and editing.
* this is purely for UI purposes and does not modify it at the schema level
*/
required?: boolean | ((value?: any) => boolean);
/**
* allows the creation of this field
*/
creatable?: boolean;
/**
* use a textarea input instead of a regular text input.
* this is only considered if property is a string
*/
textarea?: boolean;
/**
* displays the field value as an image instead of the url string,
* assuming the value is a valid image url
*/
as_image?: boolean;
/**
* customize how fields are displayed for different views.
* renderer functions are expected to return html as a string, and input
* elements in forms are expected to contain `name` attribute matching the field name
*/
renderers?: Renderers;
}
interface Renderers {
/**
* customize how the value appears in the table view
*/
list?: (value: any) => string;
/**
* customize the input field in create form
*/
create?: (enumvalues: any[] | null) => string;
/**
* customize how the filter input appears
*/
filter?: (enumvalues: any[] | null) => string;
/**
* customize how the value appears in detail view
*/
view?: (value: any) => string;
/**
* customize the input field in edit form
*/
edit?: (params: { value: any; enumvalues: any[] | null }) => string;
}
complete example
const explorer = new MongooseExplorer({
mongoose,
resources: {
User: {
explorable: true,
creatable: false,
deletable: (user) => doc.role !== "admin",
editable: (user) => !user.active,
sortable: true,
limit: 50,
viewables: ["email", "role"],
filterables: ["role"],
creatables: ["email", "username", "role"], // redundant since creatable is false
editables: ["role", "active"],
sortables: ["role", "active"],
ref_newtab: true,
virtuals: {
info: (user) => `${user.username} ${user.email} - ${user.role}`
},
show_indexes: true,
query_runner: {
enabled: true,
presets: [
{
name: "Inactive Users",
resolver: () =>
User.find({
last_login: {
$lt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
}
}).lean()
}
]
},
actions: {
create: async (body) => {
const user = await User.create(body);
await services.email.verification(user.email);
return user;
},
delete: async (user) => {
await user.deleteOne();
await posthog.capture("user.deleted", user.toObject());
return user;
},
custom: {
bulk: [
{
operation: "ban",
execute: async (ids) => {
await User.updateMany(
{ _id: { $in: ids } },
{ $set: { is_banned: true } }
);
},
guard: true,
element: {
variant: "filled",
color: "danger",
size: "lg",
label: "BAN",
style: "border-radius: 10px"
}
}
],
document: [
{
operation: "promote-to-admin",
execute: async (user) => {
await user.set("role", "admin").save();
},
applicable: (user) => user.role !== "admin",
guard: "promote to admin fr?",
element: {
variant: "outline",
color: "primary",
size: "md",
label: "Promote To Admin",
style: "font-style: italic; font-weight: bold"
}
}
]
}
},
bulk_delete: {
enabled: true,
use_document: true
},
cascade_delete: [
{
model: Post,
relation: {
local_field: "_id",
foreign_field: "author_id"
}
},
{
model: Notification,
relation: {
local_field: "_id",
foreign_field: "recipient_id"
}
}
],
// or manually
cascade_delete: async (doc, session) => {
if (!session)
await Promise.all([
Post.deleteMany({ author_id: doc._id }),
Notification.deleteMany({ recipient_id: doc._id })
]);
else {
await Post.deleteMany({ author_id: doc._id }, { session });
await Notification.deleteMany({ recipient_id: doc._id }, { session });
}
},
properties: {
email: {
label: "E-Mail",
editable: false,
sortable: false,
viewable: true,
filterable: false,
required: true,
creatable: true,
textarea: false,
as_image: false,
renderers: {
list: (email) => mask(email),
create: () => `
<input type="email" name="email" class="input size-sm" style="border-radius: 20px"></input>
`,
view: (email) => mask(email)
}
},
/**
* target properties nested within an object by using dot notation
*/
"profile.settings.notificatons": {}
}
}
}
});
pages
- type:
CustomPage[]
- description: custom pages that will be rendered at custom routes
CustomPage
definition
interface CustomPage {
/**
* path where the custom page will be mounted.
* this value must not be prefixed with `/` and must be unique
*/
path: string;
/**
* absolute path to the custom pug file for rendering the page
*/
pug: string;
/**
* the label for the custom page link, which will be displayed in the navigation.
* if not provided, defaults to the `path` value
*/
label?: string;
/**
* additional html to inject into the <head> section.
* useful for including external resources like stylesheets, remote scripts, etc
*/
head?: string;
/**
* page specific javascript.
* can either be an absolute path to a javascript file or inline javascript as a string
*/
js?: string;
/**
* page specific styles.
* can either be an absolute path to a css file or inline css as a string
*/
css?: string;
/**
* define local variables for the page
*/
locals?: (req: Request) => object | Promise<object>;
/**
* action handler for the custom page. this will be triggered when a POST
* request is made in the page route.
* to use this, include a form with `method="post"` in the page.
* `params.body` is the parsed url-encoded body, while the raw body is still available via `req.body`
*/
action?: (params: { req: Request; body: object }) => void | Promise<void>;
/**
* joi schema for validating `req.body` in the action handler.
* the following validation options are applied by default:
* - `allowUnknown: false`
* - `presence: "required"`
* - `abortEarly: false`
* - `errors.wrap.label: false`
*/
schema?: joi.ObjectSchema;
}
template rendering
within the pug template, you'll have access to the following:
theme: Theme
: the theme object. see theming for more detailslink: (path: string) => string
: a function that generates links to other custom pages
example
new MongooseExplorer({
mongoose,
pages: [
{
path: "analytics",
pug: "/path/to/custom-analytics.pug",
label: "Analytics Dashboard",
locals: async (req) => {
const analytics = await calculcate_analytics();
return {
user: req.user,
analytics
};
}
}
]
});
access_control
- type:
ExplorerAccessControlPolicy
- description: the access control system allows you to define granular permissions for different roles at both the explorer and resource levels. this provides fine-grained control over what actions users can perform and what data they can access
explorer-level access control
ExplorerAccessControlPolicy
definition
interface ExplorerAccessControlPolicy {
/**
* determines user's role from the request.
* crucial for the entire access control system
*/
role_resolver: (req: Request) => string;
/**
* role-based access rules
*/
rules?: Record<Role, ExplorerAccessControlRule>;
/**
* dynamically modify permissions
*/
augment?: (
role: string,
req: Request,
rule: ExplorerAccessControlRule
) =>
| string
| { rule: ExplorerAccessControlRule; replace?: boolean }
| Promise<string>
| Promise<{ rule: ExplorerAccessControlRule; replace?: boolean }>;
}
interface ExplorerAccessControlRule {
/**
* controls which pages are accessible to users based on their roles.
* page names or "*" for full access.
* or object specifying view/action permissions per page
*/
pages?: string[] | Record<string, PageAccessControl>;
/**
* controls which resources are accessible to users based on their roles
*/
resources?: string[];
/**
* controls which widgets are accessible to users based on their roles
*/
widgets?: string[];
}
interface PageAccessControl {
/**
* whether the role can view the page
*/
view: boolean;
/**
* whether the role can execute the page's action
*/
action: boolean;
}
type Role = string;
example
new MongooseExplorer({
mongoose,
access_control: {
role_resolver: (req) => req.user.role,
rules: {
"super-admin": {
resources: ["*"], // full access to all resources
pages: ["*"], // full access to all pages and actions
widgets: ["*"] // full access to all widgets
},
admin: {
resources: ["User", "Post", "AuditLog", "Ticket"], // access to these specific resources
pages: {
"*": {
// default permissions for all pages
view: true,
action: false
},
"emergency-actions": {
// override for specific page
view: false,
action: false
}
},
widgets: ["total-users", "monthly-signups"]
},
moderator: {
resources: [], // no access to any resource
pages: ["content-moderation"],
widgets: [] // no access to any widget
}
},
augment: async (role, req, rule) => {
if (role !== "super-admin" && (await is_on_duty(req.user)))
return {
pages: {
"emergency-actions": {
view: true,
action: true
}
}
};
return {};
}
}
});
resource-level access control
ResourceAccessControlPolicy
definition
interface ResourceAccessControlPolicy {
rules: Record<Role, ResourceAccessControlRule>;
augment?: (
role: string,
req: Request,
rule: ResourceAccessControlRule
) =>
| string
| { rule: ResourceAccessControlRule; replace?: boolean }
| Promise<string>
| Promise<{ rule: ResourceAccessControlRule; replace?: boolean }>;
}
interface ResourceAccessControlRule {
/**
* allowed operations on the resource
*/
operations: (
| "create"
| "read"
| "update"
| "delete"
| "bulk_delete"
| "query_runner"
| "*"
)[];
/**
* field-level access control
*/
fields?: {
/**
* fields that can be viewed
*/
viewables?: string[];
/**
* fields that can be set during creation of documents of the resource
*/
creatables?: string[];
/**
* fields that can be modified
*/
editables?: string[];
/**
* fields that can be used for filtering
*/
filterables?: string[];
/**
* fields that can be used for sorting
*/
sortables?: string[];
};
/**
* query runner permissions
*/
query_runner?: {
/**
* allowed operation
*/
operations?: (
| "find"
| "findOne"
| "aggregate"
| "countDocuments"
| "estimatedDocumentCount"
| "*"
)[];
/**
* allowed preset queries
* "*" for all presets or specific preset names
*/
presets?: string[];
};
/**
* custom actions permissions
*/
actions?: {
/**
* allowed bulk actions
* "*" for all actions
*/
bulk?: string[];
/**
* allowed document actions
* "*" for all actions
*/
document?: string[];
};
}
example
{
resources: {
User: {
access_control: {
rules: {
admin: {
operations: ["read", "update"],
fields: {
viewables: ["*"],
editables: ["status", "role"],
filterables: ["status", "role", "created_at"],
sortables: ["created_at", "status"]
},
query_runner: {
operations: ["find", "countDocuments"],
presets: ["active users", "recent signups"]
},
actions: {
bulk: ["verify emails"],
document: ["send verification email", "send welcome email"]
}
},
moderator: {
operations: ["read"],
fields: {
viewables: ["username", "email", "status"],
filterables: ["status"],
sortables: ["created_at"]
},
query_runner: {
operations: ["find"],
presets: ["reported users"]
},
actions: {
document: ["flag for review"]
}
}
},
augment: async (role, req, rule) => {
if (await has_temporary_elevated_access(req.user))
return {
operations: ["create", "delete"],
fields: {
editables: ["*"]
}
};
return {};
}
}
}
}
}
key principles
- the
role_resolver
is the single source of truth for roles. it determines the user's role, which is used for both explorer-level and resource-level access control. - if no access control rules are specified for a role or resource, full access is granted by default.
- in custom pages, a
can_execute_action
variable is available as a local variable. this can be used to conditionally render action forms based on the user's permissions. - use
*
wildcard for full access in any category. - the
augment
function allows for dynamic modification of access rules based on runtime conditions.- can return three types of modification:
- a new role string to change the user's role
- a modified rule object with optional
replace
flag - an object with a new rule and
replace
flag
- if
replace
istrue
, the returned rule completely replaces the existing rule, otherwise the rule is merged with the existing rule
- can return three types of modification:
- returning
"*"
wildcard from theaugment
function grants full access by removing all restrictions. this is a shorthand alternative to returning a complete rule structure with wildcards. - explorer-level access control and resource-level access control are distinct
features and do not directly interact with each other. however, the
role_resolver
function, which is configured at the explorer level, is crucial for resource-level access control. - exclusion patterns using
-
prefix are supported for explorer-level and resource-level rules (except fields). - when using string arrays in
ExplorerAccessControlRule.pages
, page names are case-insensitive when matching against custom page paths. however, all other string configurations throughout the access control system (includingpages
when using object syntax) require exact case matching.
custom pages and access control
if can_execute_action
form(method="post")
// form fields
button(type="submit") submit
this ensures that the action form is only displayed to users with the appropriate permissions, as determined by the access control system
events
event system that allows you to hook into various events that occur within the the explorer. this is particularly useful for implementing audit logs of actions performed by users, monitoring system activity, or triggering side effects
available events
create
: triggered when a new document is createdupdate
: triggered when a document is modifieddelete
: triggered when a document is deletedbulk_delete
: triggered when multiple documents are deletedquery_runner
: triggered when a custom query is executedbulk_action
: triggered for bulk operationsdocument_action
: triggered for single document operationspage_action
: triggered for custom page interactions
each event passes two parameters to the handler function:
req
: the request objectevent
: an object containing details about the event
event type definitions
interface CreateEvent {
timestamp: Date;
model: string;
operation: "create";
doc: RawDoc;
}
interface UpdateEvent {
timestamp: Date;
model: string;
operation: "update";
doc: RawDoc;
changes: {
previous: AnyObject;
current: AnyObject;
};
}
interface DeleteEvent {
timestamp: Date;
model: string;
operation: "delete";
doc: RawDoc;
}
interface BulkDeleteEvent {
timestamp: Date;
model: string;
operation: "bulk_delete";
document_ids: mongoose.Types.ObjectId[];
}
interface QueryRunnerEvent {
timestamp: Date;
model: string;
operation: "query_runner";
sub_operation: string;
query: AnyObject | mongoose.PipelineStage[];
options: mongoose.QueryOptions;
}
interface BulkActionEvent {
timestamp: Date;
model: string;
operation: "bulk_action";
sub_operation: string;
document_ids: mongoose.Types.ObjectId[];
}
interface DocumentActionEvent {
timestamp: Date;
model: string;
operation: "document_action";
sub_operation: string;
doc: RawDoc;
}
interface PageActionEvent {
timestamp: Date;
operation: "page_action";
page: string;
body: AnyObject;
}
type RawDoc = {
_id: mongoose.Types.ObjectId;
} & AnyObject;
suggested audit log schema
to keep an audit log, you can create a mongoose model based on the events described. note that this is just a suggestion - you can modify it according to your specific needs
const ExplorerAuditLogSchema = new mongoose.Schema({
timestamp: {
type: Date,
required: true
},
model: {
type: String,
required: true
},
actor_id: {
type: mongoose.Schema.Types.ObjectId,
ref: "User",
required: true
},
operation: {
type: String,
required: true,
enum: [
"create",
"update",
"delete",
"bulk_delete",
"query_runner",
"bulk_action",
"document_action",
"page_action"
]
},
document_id: {
type: mongoose.Schema.Types.ObjectId,
refPath: "model
},
document_ids: {
type: [mongoose.Schema.Types.ObjectId],
default: undefined,
refPath: "model"
},
changes: {
previous: mongoose.Schema.Types.Mixed,
current: mongoose.Schema.Types.Mixed
},
query: mongoose.Schema.Types.Mixed,
query_options: mongoose.Schema.Types.Mixed,
page: String,
body: mongoose.Schema.Types.Mixed,
metadata: mongoose.Schema.Types.Mixed
});
ExplorerAuditLogSchema.index({ timestamp: -1 });
ExplorerAuditLogSchema.index({ operation: 1 });
ExplorerAuditLogSchema.index({ model: 1 });
ExplorerAuditLogSchema.index({ actor_id: 1 });
ExplorerAuditLogSchema.index({ document_id: 1 }, { sparse: true });
const ExplorerAuditLog = mongoose.model(
"ExplorerAuditLog",
ExplorerAuditLogSchema
);
example usage
const explorer = new MongooseExplorer({ mongoose });
explorer.on("create", async (req, event) => {
await ExplorerAuditLog.create({
operation: event.operation,
model: event.model,
document_id: event.doc._id,
actor_id: req.user._id,
timestamp: event.timestamp
});
});
explorer.on("update", async (req, event) => {
await ExplorerAuditLog.create({
operation: event.operation,
model: event.model,
document_id: event.doc._id,
changes: event.changes,
actor_id: req.user._id,
timestamp: event.timestamp
});
});
explorer.on("delete", async (req, event) => {
await ExplorerAuditLog.create({
operation: event.operation,
model: event.model,
timestamp: event.timestamp,
actor_id: req.user._id,
metadata: {
deleted_document: event.doc
}
});
});
remember that this is just a suggested schema - you can modify it based on your specific needs. the key is to capture the information that's most relevant for your use case
features and behavior
1. string filtering with regex
the interface supports filtering strings using regex patterns enclosed
within /
delimiters. example: a filter input of /doe/i/
is processed
as /doe/i
and applied using mongodb's $regex
operator
2. query identification: is_explorer_operation
- type:
(subject: mongoose.Query | mongoose.Document | mongoose.Aggregate) => boolean
- description: a utility function to determine if any operation originates from the explorer. this is helpful for configuring middlewares that should behave differently for operations originated from the explorer
example usage
import { is_explorer_operation } from "@kamalyb/mongoose-explore";
schema.pre(/^find/, function (next) {
if (!is_explorer_operation(this)) this.where({ deleted: { $ne: true } });
next();
});
schema.pre("aggregate", function (next) {
if (!is_explorer_operation(this))
this.pipeline().unshift({ $match: { deleted: { $ne: true } } });
next();
});
schema.pre("deleteOne", { query: false, document: true }, function (next) {
if (is_explorer_operation(this)) something();
next();
});
schema.post("save", function (next) {
if (!is_explorer_operation(this)) another();
next();
});
3. inclusion and exclusion patterns
- inclusion and exclusion patterns cannot be mixed in the same array
- if any item in an array starts with
-
, the entire array is treated as an exclusion list
limitations
- filtering properties of type map is not supported
- filtering and editing properties of type mixed and array of objects is not supported
- sorting properties of type map, mixed, and array is not supported