In this post, we apply refine's built-in audit logging functionality to our Pixels Admin app and to the Pixels client app that we built previously in this refineWeek series. refine's audit logging system comes already baked into its data hooks and inside supplemental data provider packages, like the @pankod/refine-supabase
. Today we are going to get it to work by using the auditLogProvider
prop.
This is Day 7, and refineWeek is a quickfire tutorial guide that aims to help developers learn the ins-and-outs of refine's powerful capabilities and get going with refine within a week.
Overview
In this series, we have been exploring refine's internals by building two apps: the Pixels client that allows users to create and collboratively draw on a canvas, and the Pixels Admin app that allows admins and editors to manage canvases created by users.
We implemented CRUD actions for Pixels client app on Day 3 and for the admin app on Day 5. In this episode, we enable audit logging on database mutations by defining the auditLogProvider
object and passing it to <Refine />
.
We are using refine's supplemental Supabase @pankod/refine-supabase
package for our dataProvider
client. The database mutation methods in Supabase dataProvider
already come with audit logging mechanism implemented on them. For each successful database mutation, i.e. create
, update
and delete
actions, a log event is emitted and a params
object representing the change is made available to the auditLogProvider.create()
method.
We will store the log entries in a logs
table in our Supabase database. So, we have to set up the logs
table with a shape that complies with the params
object sent from the mutation.
We will start by examining the shape of the params
object and specifying how the logs
table should look like - before we go ahead and create the table with appropriate columns from our Supabase dashboard. We will then work on the auditLogProvider
methods, and use the useLogList()
hook to list pixels
logs inside a modal for each canvas item. Finally, like we did in other parts, we will dig into the existing code to explore how refine emits a log event and how mutation methods implement audit logging under the hood.
Let's dive in!
logs
Table for refine Audit Logs
We need to set up the logs
table from the Supabase dashboard. But let's first figure out the columns we need for the table. The table should have as columns the properties of the log params
object emitted by a mutation.
refine's Log Params Object
A successful resource create
action makes the following log params
object available to the auditLogProvider.create()
method:
{
"action": "create",
"resource": "pixels",
"data": {
"id": "1",
"x": "3",
"y": "3",
"color": "cyan",
},
"meta": {
"dataProviderName": "Google",
"id": "1"
}
}
This object should be passed to the audit log provider's create
method in order to create a new record in the logs
table.
Likewise, the update
and delete
actions of a resource - for example, pixels
- emit an object with similar, overlapping variations. More on that here.
It is important not to confuse a resource create
action with that of the auditLogProvider
. The resource create
action is carried out by the dataProvider.create()
method and produces the log params
object. The auditLogProvider.create()
method consumes the params
object and creates an entry in the logs
table.
For our case, we are focused on logging the pixels
create
actions on a canvas in our Pixels client app.
The meta
Object
Notice, the meta.id
property on the log params
object above. It represents the id
of the resource item on which the event was created.
It is possible to append useful data to the meta
field by passing the data to the metaData
object when the mutation is invoked from a hook. For example, we can add the canvas
property to the metaData
object inside the argument passed to the mutate
function of the useCreate()
hook:
const { mutate } = useCreate();
mutate({
resource: "pixels",
values: { x, y, color, canvas_id: canvas?.id, user_id: identity.id },
metaData: {
canvas,
},
});
And it will be included in the log params
object's meta
field:
{
"action": "create",
"resource": "pixels",
"author": {
"id": ""
// ...other_properties
},
"data": {
"id": "1",
"x": "3",
"y": "3",
"color": "cyan",
},
"meta": {
"dataProviderName": "Google",
"id": "1",
"canvas": {
"id": "",
// ...etc.
},
}
}
Properties inside the meta
object are handy for filtering get
requests to the logs
table. We are going to use this when we define the auditLogProvider.get()
method.
Notice also the author
property. It is added when a user is authenticated. Otherwise, it is excluded.
The logs
Table
Emanating from the log params object above, our logs
table looks like this:
Let's go ahead and create this table from our Supabase dashboard before we move forward and start working on the auditLogProvider
methods.
<Refine />
's auditLogProvider
Object
<Refine />
's audit log provider object should have three methods. It has the following type signature:
const auditLogProvider = {
create: (params: {
resource: string;
action: string;
data?: any;
author?: {
name?: string;
[key: string]: any;
};
previousData?: any;
meta?: Record<string, any>;
}) => void;
get: (params: {
resource: string;
action?: string;
meta?: Record<string, any>;
author?: Record<string, any>;
metaData?: MetaDataQuery;
}) => Promise<any>;
update: (params: {
id: BaseKey;
name: string;
}) => Promise<any>;
};
Based on this, our auditLogProvider
looks like this:
// providers/auditLogProvider.ts
import { AuditLogProvider } from "@pankod/refine-core";
import { dataProvider } from "@pankod/refine-supabase";
import { supabaseClient } from "utility";
export const auditLogProvider: AuditLogProvider = {
create: params => {
return dataProvider(supabaseClient).create({
resource: "logs",
variables: params,
});
},
update: async ({ id, name }) => {
const { data } = await dataProvider(supabaseClient).update({
resource: "logs",
id,
variables: { name },
});
return data;
},
get: async ({ resource, meta }) => {
const { data } = await dataProvider(supabaseClient).getList({
resource: "logs",
filters: [
{
field: "resource",
operator: "eq",
value: resource,
},
{
field: "meta->canvas->id",
operator: "eq",
value: `"${meta?.canvas?.id}"`,
},
],
sort: [{ order: "desc", field: "created_at" }],
});
return data;
},
};
We'll analyze all three methods in the below sections.
create
The create
method is very straightforward. It just takes the log params
object sent when the log event was emitted, and adds an entry to the logs
table.
It is called when any of the three mutation actions, namely create
, update
and delete
is completed successfully.
update
The update
method is similar. Our implementation allows updating the name
of the log item. Hence we need to add a name
column in our database. If you haven't already noticed it, we have a name
column in our logs
table and this is the reason. The update
methods queries the database with the id
of the log entry and allows updating its name
. More information is available in this section.
get
The get
method is the most significant of the three, especially with the use of the meta
argument. What we're doing first is using the dataProvider.getList()
method to query the logs
table.
Then inside the filters
array, we're first filtering log
records with the resource
field and then with the nested embedded field of meta->canvas->id
. As we will encounter in the next section, the canvas
property will be appended to the meta
field of the log params
object. It will be done by passing the canvas
to the metaData
object of the argument passed to the mutation method of useCreate()
data hook. It will therefore be stored in the log
record.
When we want to query the logs
table, we will use the useLogList()
audit log hook that consumes the get()
method. The meta?.canvas?.id
comes from the meta
argument passed to useLogList()
.
With this done, we are ready to log all pixels
creations and show the pixels
log list for each of our canvases.
Audit Logging with refine
In order to enable audit logging feature in our app, we have to first pass the auditLogProvider
prop to <Refine />
. Since pixels
are being created in the Pixels app, that's where we are going to implement it:
// App.tsx
<Refine
...
auditLogProvider={auditLogProvider}
/>
This makes all database mutations emit a log event and send the log params
object towards the auditLogProvider.create()
method. Mutations that emit an event are create()
, update()
and delete()
methods of the dataProvider
object.
When these methods are consumed from components using corresponding hooks, and given the logs
table is set up properly, a successful mutation creates an entry in the logs
table.
Audit Log create
Action
In the Pixels app, pixels
are created by the onSubmit()
event handler defined inside the <CanvasShow />
component. The <CanvasShow />
component looks like this:
// pages/canvases/show.tsx
import { useState } from "react";
import {
useCreate,
useGetIdentity,
useNavigation,
useShow,
} from "@pankod/refine-core";
import {
Button,
Typography,
Icons,
Spin,
Modal,
useModal,
} from "@pankod/refine-antd";
import { CanvasItem, DisplayCanvas } from "components/canvas";
import { ColorSelect } from "components/color-select";
import { AvatarPanel } from "components/avatar";
import { colors } from "utility";
import { Canvas } from "types";
import { LogList } from "components/logs";
const { LeftOutlined } = Icons;
const { Title } = Typography;
export const CanvasShow: React.FC = () => {
const [color, setColor] = useState<typeof colors[number]>("black");
const { modalProps, show, close } = useModal();
const { data: identity } = useGetIdentity();
const {
queryResult: { data: { data: canvas } = {} },
} = useShow<Canvas>();
const { mutate } = useCreate();
const { list, push } = useNavigation();
const onSubmit = (x: number, y: number) => {
if (!identity) {
return push("/login");
}
if (typeof x === "number" && typeof y === "number" && canvas?.id) {
mutate({
resource: "pixels",
values: {
x,
y,
color,
canvas_id: canvas?.id,
user_id: identity.id,
},
metaData: {
canvas,
},
successNotification: false,
});
}
};
return (
<div className="container">
<div className="paper">
<div className="paper-header">
<Button
type="text"
onClick={() => list("canvases")}
style={{ textTransform: "uppercase" }}
>
<LeftOutlined />
Back
</Button>
<Title level={3}>{canvas?.name ?? canvas?.id ?? ""}</Title>
<Button type="primary" onClick={show}>
View Changes
</Button>
</div>
<Modal
title="Canvas Changes"
{...modalProps}
centered
destroyOnClose
onOk={close}
onCancel={() => {
close();
}}
footer={[
<Button type="primary" key="close" onClick={close}>
Close
</Button>,
]}
>
<LogList currentCanvas={canvas} />
</Modal>
{canvas ? (
<DisplayCanvas canvas={canvas}>
{(pixels) =>
pixels ? (
<div
style={{
display: "flex",
justifyContent: "center",
gap: 48,
}}
>
<div>
<ColorSelect
selected={color}
onChange={setColor}
/>
</div>
<CanvasItem
canvas={canvas}
pixels={pixels}
onPixelClick={onSubmit}
scale={(20 / (canvas?.width ?? 20)) * 2}
active={true}
/>
<div style={{ width: 120 }}>
<AvatarPanel pixels={pixels} />
</div>
</div>
) : (
<div className="spin-wrapper">
<Spin />
</div>
)
}
</DisplayCanvas>
) : (
<div className="spin-wrapper">
<Spin />
</div>
)}
</div>
</div>
);
};
The mutate()
function being invoked inside onSubmnit()
handler is destrcutured from the useCreate()
hook. We know that audit logging has been activated for the useCreate()
hooks, so a successful pixels
creation sends the params
object to auditLogProvider.create
method.
Notice that we are passing the currentCanvas
as metaData.canvas
, which we expect to be populated inside the meta
property of the log params
object. As we'll see below, we are going to use it to filter our GET
request to the logs
table using useLogList()
hook.
Audit Log List with refine
We are going to display the pixels
log list for a canvas in the <LogList />
component. In the Pixels app, it is contained in the <CanvasShow />
page and housed inside a modal accessible by clicking on the View Changes
button. The <LogList />
component uses the useLogList()
hook to query the logs
table:
// components/logs/list.tsx
import React from "react";
import { useLogList } from "@pankod/refine-core";
import { Avatar, AntdList, Typography } from "@pankod/refine-antd";
import { formattedDate, timeFromNow } from "utility/time";
type TLogListProps = {
currentCanvas: any;
};
export const LogList = ({ currentCanvas }: TLogListProps) => {
const { data } = useLogList({
resource: "pixels",
meta: {
canvas: currentCanvas,
},
});
return (
<AntdList
size="small"
dataSource={data}
renderItem={(item: any) => (
<AntdList.Item>
<AntdList.Item.Meta
avatar={
<Avatar
src={item?.author?.user_metadata?.avatar_url}
size={20}
/>
}
/>
<Typography.Text style={{ fontSize: "12px" }}>
<strong>{item?.author?.user_metadata?.email}</strong>
{` ${item.action}d a pixel on canvas: `}
<strong>{`${item?.meta?.canvas?.name} `}</strong>
<span
style={{ fontSize: "10px", color: "#9c9c9c" }}
>{`${formattedDate(item.created_at)} - ${timeFromNow(
item.created_at,
)} ago`}</span>
</Typography.Text>
</AntdList.Item>
)}
/>
);
};
If we examine closely, the meta
property of the argument object passed to useLogList()
hook contains the canvas
against which we want to filter the logs
table. If we revisit the auditLogProvider.create
method, we can see that the value
field of the second filter corresponds to this canvas:
{
field: "meta->canvas->id",
operator: "eq",
value: `"${meta?.canvas?.id}"`,
}
We are doing this to make sure that we are getting only the logs for the current canvas.
With this completed, if we ask for the modal in the CanvasShow
page, we should be able to see the pixels log list:
We don't have a case for creating a pixel in the Pixels Admin app. But we can go ahead and implement the same pixels <LogList />
component for each canvas
item in the <CanvasList />
page at /canvases
. The code is essentially the same, but the View Changes
button appears inside each row in the table:
Low Level Inspection
We are now going to examine how audit logging comes built-in inside refine's mutation hooks.
Log params
Object
We mentioned earlier that each successful mutation emits a log event and sends a params
object to the auditLogProvider.create()
method. Let's dig into the code to see how it is done.
The log params
object is sent to the auditLogProvider.create()
method from inside the log
object returned from the useLog()
hook:
// @pankod/refine-core/src/hooks/useLog/index.ts/useLog/log
// v3.90.6
const log = useMutation<TLogData, Error, LogParams, unknown>(
async (params) => {
const resource = resources.find((p) => p.name === params.resource);
const logPermissions = resource?.options?.auditLog?.permissions;
if (logPermissions) {
if (!hasPermission(logPermissions, params.action)) {
return;
}
}
let authorData;
if (isLoading) {
authorData = await refetch();
}
return await auditLogContext.create?.({
...params,
author: identityData ?? authorData?.data,
});
},
);
As we can see above, params
is made available by reaching the provider via the auditLogContext.create()
method.
Prior to that, the log
object here utilizes react-query
's useMutation()
hook to catch the results of the mutation with an observer and emit the event.
Inside Mutation Hooks
Inside mutation hooks, the useLog()
hook is used to create a log automatically after a successful resource mutation. For example, the useCreate()
data hook implements it with the mutate
method on log
object returned from useLog()
:
// @pankod/refine-core/src/hooks/data/useCreate.ts
// v3.90.6
log?.mutate({
action: "create",
resource,
data: values,
meta: {
dataProviderName: pickDataProvider(
resource,
dataProviderName,
resources,
),
id: data?.data?.id ?? undefined,
...rest,
},
});
The code snippets above are enough to give us a peek inside what is going, but feel free to explore the entire files for more insight.
Summary
In this episode, we activated refine's built-in audit logging feature by defining and passing the auditLogProvider
prop to <Refine />
. We we learned that refine implements audit logging from its resource mutation hooks by sending a log params
object to the auditProvider.create()
method, and when audit loggin is activated, every successful mutation creates an entry in the logs
table.
We implemented audit logging for create
actions of the pixels
resource in our Pixels app and saved the entries in a logs
table in our Supabase database. We then fetched the pixel creation logs for each canvas using the useLoglist()
hook and displayed the in a modal. We leverage the meta
property of the log params
object in order to filter our auditProvider.get()
request.
Series Wrap Up
In this refineWeek series, built the following two apps with refine:
Pixels - the client app that allows users to create a canvas and draw collaboratively on
Pixels Admin - the admin dashboard that helps managers manage users and canvases
While building these twp apps, we have covered core refine concepts like the providers and hooks in significant depth. We had the opportunity to use majority of the providers with the features we added to these apps. Below is the brief outline of the providers we learned about:
-
authProvider
: used to handling authentication. We used it to implement email / password based authentiction as well as social logins with Google and GitHub. -
dataProvider
: used to fetch data to and from a backend API by sending HTTP requests. We used the supplementary Supabase package to build a gallery of canvases, a public dashboard and a private dashboard for role based managers. -
routerProvider
: used for routing. We briefly touched over how it handles routing and resources following RESTful conventions. -
liveProvider
: used to implement real time Publish Subscribe features. We used it for allowing users to draw pixels collaboratively on a canvas. -
accessControlProvider
: used to implement authorization. We implemented a Role Based Access Control authorization foreditor
andadmin
roles. -
auditLogProvider
: used for logging resource mutations. We used it to log and display pixels drawing activities on a canvas. -
notificationProvider
: used for posting notifications for resource actions. We did not cover it, but used it inside our code.
There are more to refine than what we have covered in this series. We have made great strides in covering these topics so far by going through the documentation, especially to understand the provider - hooks interactions.
We also covered supplementary Supabase anhd Ant Design packages. refine has fantastic support for Ant Design components. And we have seen how refine-antd components complement data fetching by the data providers and help readily present the response data with hooks like useSimpleList()
, useTable()
and useEditableTable()
.
We can always build on what we have covered so far. There are plenty of things that we can do moving froward, like customizing the layout, header, auth pages, how exactly the notificationProvider
works, how to implement the i18nProvider
, etc.
Please feel free to reach out to the refine team or join the refine Discord channel to learn more and / or contribute!