A SaaS application typically implements a role-based access control system to manage user permissions and restrict access to specific features or content.
For instance, free-tier users may only have access to basic functionalities and limited content, while paid users can unlock premium features and exclusive resources.
Additionally, administrators are granted elevated privileges, enabling them to oversee and manage the entire system, including user accounts, content, and application settings.
This tiered, role-based access structure ensures a secure and organized user experience.
Extending the User table
In order to implement such system using Auth.js, we must expand the default User
table to include information about the user's role.
Go to prisma/schema.prisma
and add a role
column like this:
1model User {
2 id String @id @default(cuid())
3 name String?
4 email String? @unique
5 emailVerified DateTime?
6 image String?
7 role String? @default("free user") // Add a new "role" column
8 accounts Account[]
9 sessions Session[]
10
11 createdAt DateTime @default(now())
12 updatedAt DateTime @updatedAt
13}
Apply the new schema by running the following command:
1npx prisma migrate dev
You should see a new role
column inside the User
table. For now, just for demonstration purposes, you can manually change its value:
You may need to restart the dev server in order for the changes to take effect.
Extending the session
Previously, we discussed how to protect certain routes by checking for sessions.
In order to implement a role-based access control system, you must also extend the default session to mirror the User
table.
Go to the auth.js
and add a session()
callback:
libs/auth.js
1. . .
2
3export const { handlers, signIn, signOut, auth } = NextAuth({
4 adapter: PrismaAdapter(prisma),
5 providers: [. . .],
6 pages: {. . .},
7 session: {. . .},
8 callbacks: {
9 session({ session, user }) {
10 session.user.role = user.role;
11 return session;
12 },
13 },
14});
The session()
callback will be executed every time the session is checked, by executing auth()
. And our example makes sure that the returned session contains a session.user.role
key that mirrors user.role
from the database.
Implementing the access control logic
And finally, go to your page.jsx
and check for the session?.user?.role
instead of just the session
:
page.jsx
1import { auth } from "@/auth";
2
3export default async function Page() {
4 const session = await auth();
5
6 if (session?.user?.role === "admin") {
7 return <p>You are an admin, welcome!</p>;
8 }
9
10 return <p>You are not authorized to view this page!</p>;
11}
The ?.
is optional chaining operator, which provides a safer way (compared to .
) to access a property deeply nested within an object.
If any of the chained properties doe not exist, it returns undefined
instead of throwing an error.
Updating the user settings page
1import { auth } from "@/libs/auth";
2import prisma from "@/libs/db";
3import { redirect } from "next/navigation";
4
5export default async function SettingsPage() {
6 const session = await auth();
7
8 return (
9 <div className="flex min-h-[80vh] items-center justify-center bg-gray-100">
10 <div className="w-full max-w-md bg-white p-8 rounded-lg shadow-md">
11 <h1 className="text-2xl font-bold text-center mb-6">User Settings</h1>
12
13 <form
14 action={. . .}
15 className="space-y-6">
16 <div>
17 . . .
18 <div>
19 <label
20 htmlFor="role"
21 className="block text-sm font-medium text-gray-700">
22 Role
23 </label>
24 <input
25 type="text"
26 id="role"
27 name="role"
28 defaultValue={session?.user?.role}
29 className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-100 cursor-not-allowed"
30 readOnly
31 />
32 </div>
33
34 <div>
35 <button
36 type="submit"
37 className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500">
38 Update Profile
39 </button>
40 </div>
41 </form>
42 </div>
43 </div>
44 );
45}