Building An Admin Console With Minimum C
Building An Admin Console With Minimum C
Building a customer-facing application is exciting. But it's not much fun when it
comes to the admin console part. However, almost every serious app requires some
sort of admin console for operation needs. It doesn't need to be slick in design or
have blazing-fast performance. The main focus should be reliability, cost-
effectiveness, and extensibility.
There are many different types of admin consoles. In this post, we'll discuss the
most common ones: those that allow non-technical people to make changes to the
database and ensure proper permission management at the same time.
Don't build it. Just use a database editor (like phpMyAdmin, Prisma Studio, etc.)
for the job.
Quickly construct one from scratch by combining high-level libraries and tools.
Build it the same way as you would build the customer-facing app.
Each choice has its unique pros and cons. This post will focus on the 2nd, which
tends to have the best balance between cost and quality for most real-world
applications.
Prisma
Prisma is a modern TypeScript-first ORM that allows you to manage database schemas
easily, make queries and mutations with great flexibility, and ensure excellent
type safety.
ZenStack
ZenStack is a toolkit built above Prisma that adds access control, automatic CRUD
web API, etc. It unleashes the ORM's full power for full-stack development.
Author: who can create posts and submit them for editorial review.
Editor: who can make editorial changes (to other people's posts) and publish posts.
Admin: who can do anything.
A blog post can be in one of the following statuses:
Draft
The author is working on it privately. Only the author and admin can access it.
Submitted
The author has submitted it for review. Editors can read and update it.
Published
The post is published and readable to all. Editors can unpublish it.
Of course, admin users can do anything to posts of any status.
Hooking things up
An admin console is a full-stack web app, so the easiest way to build it is to use
a full-stack framework that couples the frontend and backend in a single project.
I'll use Next.js in this post, but you can also choose to go with a decoupled
frontend and backend (e.g., a Vite React SPA and an ExpressJS backend). The
fundamentals stay unchanged.
You can find the link to the completed project's GitHub repo at the end of the
post.
One thing to notice is that the zenstack init command copies prisma/schema.prisma
into /schema.zmodel. The ZModel language is the DSL ZenStack uses to model both the
database schema and other stuff (like access control policies). ZModel is a
superset of Prisma Schema. For now, we'll just author it like we would with a
Prisma schema.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./db.sqlite"
}
model User {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
email String @unique
password String @password @omit
name String?
role String @default('Author') // Author, Editor, Admin
posts Post[]
@@allow('all', true)
}
model Post {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
title String
content String
status String @default('Draft') // Draft, Submitted, Published
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
@@allow('all', true)
}
The @password attribute marks the password field to be automatically hashed (using
bcryptjs) before saving to the database. The @omit attribute marks the field to be
dropped before an entity is returned for a query. These are extensions ZenStack
made to Prisma.
The @@allow attribute defines access policies that verdict the operations allowed.
We'll allow anyone to do anything for now. Access control is the most significant
extension ZenStack made to Prisma. We'll revisit it soon.
Building authentication
React-Admin provides built-in authentication (AuthN) flow and UI. You only need to
implement an "auth provider" to adapt to your backend's AuthN mechanism. For
simplicity, we'll use a simple JWT-based authentication design with email and
password as credentials. To do that, first, create an API handler at
/src/app/api/auth/login/route.ts:
/src/app/api/auth/login/route.ts
import { compare } from "bcryptjs";
import { sign } from "jsonwebtoken";
import { db } from "~/server/db";
return Response.json({
id: user.id,
email: user.email,
token: sign(
{ sub: user.id.toString(), email: user.email, role: user.role },
process.env.JWT_SECRET!,
),
});
}
Then, implement a React-Admin auth provider, which defines how the frontend
interacts with the backend for authentication:
/src/lib/auth-provider.ts
import type { AuthProvider } from "react-admin";
logout: () => {
localStorage.removeItem("auth");
return Promise.resolve();
},
checkAuth: () =>
localStorage.getItem("auth") ? Promise.resolve() : Promise.reject(),
getIdentity: () => {
const auth = localStorage.getItem("auth");
if (!auth) {
return Promise.reject("not authenticated");
}
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { id, email }: { id: number; email: string } = JSON.parse(auth);
return Promise.resolve({ id, email });
} catch (error) {
return Promise.reject(error);
}
},
The auth provider will be used when we build the CRUD UI with React-Admin.
/src/app/api/model/[...path]/router.ts
import { type AuthUser, enhance } from "@zenstackhq/runtime";
import RestApiHandler from "@zenstackhq/server/api/rest";
import { NextRequestHandler } from "@zenstackhq/server/next";
import { type JwtPayload, verify } from "jsonwebtoken";
import type { NextRequest } from "next/server";
import { db } from "~/server/db";
// use the user identity extracted from the JWT token to access the database
return enhance(db, { user });
}
With the above code, you have a complete set of CRUD APIs served at "/api/model".
For example, you can list all users with GET /api/model/user. See the full
specification of the CRUD API here. As you can see, the code expects a JWT token in
the Authorization header, and you'll see how the frontend sends it when we get to
the part of building CRUD UI with React-Admin.
Now, let's tackle the React-Admin data provider part, which is pretty
straightforward. For brevity, I'm only showing partial code here, but you can find
the complete implementation at the end of this post.
/src/lib/data-provider.ts
type FetchFn = (url: string, init: RequestInit) => Promise<Response>;
searchParams.set(
"sort",
params.sort.order === "ASC" ? params.sort.field : `-${params.sort.field}`,
);
searchParams.set(
"page[offset]",
((params.pagination.page - 1) * params.pagination.perPage).toString(),
);
searchParams.set("page[limit]", params.pagination.perPage.toString());
return searchParams.toString();
};
return {
getList: async (resource, params) => {
const reqUrl = `${url}/${resource}?${getListQuerySearchParams(params)}`;
const { data, meta } = await doFetch(reqUrl, {
method: "GET",
});
return makeListQueryResult(data, meta, params);
},
...
} satisfies DataProvider;
}
Now we've got all the infrastructure we need. Time to move on to building the UI
now!
/src/components/AdminApp.tsx
const AdminApp = () => (
<Admin dataProvider={dataProvider} authProvider={authProvider}>
<Resource
name="user"
list={ListGuesser}
edit={EditGuesser}
create={UserCreate}
hasCreate={true}
recordRepresentation="name"
/>
<Resource
name="post"
list={PostList}
edit={EditGuesser}
create={PostCreate}
hasCreate={true}
recordRepresentation="title"
/>
</Admin>
);
You should be able to log in and CRUD users and posts now.
CRUD UI
Remember when building the CRUD API handler, we created an "enhanced" PrismaClient
to access the database for the current request? The enhanced client automatically
enforces access control. However, it's our responsibility to define the policies.
Let's start with protecting the User model.
schema.zmodel
model User {
...
// a user can update himself, and an admin can update any user
@@allow('all', auth() == this || auth().role == 'Admin')
}
Straightforward, right? In the policy rules, the special auth() function represents
the current requesting user, which is the one that we extracted from the JWT token
and passed to the enhance call previously.
Then, let's get to the Post part. There are more requirements to cover:
schema.zmodel
model Post {
...
// authors can create new posts for themselves with "Draft" status
@@allow('create', auth() == user && status == 'Draft')
A new feature here is the future() function. An "update" operation involves a pre-
state and a post-state. By default, fields in the policy rules refer to the pre-
state, but the future() function allows you to refer to the post-state.
If you rerun npx zenstack generate and restart the dev server, the access policies
will take effect. You'll notice some of the operations are rejected, for example,
if you try to set a post's status to "Published" as an author.
Operation forbidden
Conclusion
I hope you enjoyed the reading and found the approach interesting. Building an
admin console is often a less rewarding task because it doesn't directly add
customer value. However, by combining the right tools, you can make it more fun and
productive. In this post, we've only touched the surface of what React-Admin and
ZenStack can do. They both have much more to offer, and I encourage you to explore
them further.