Secure Role-Aware Next.js Access

Secure a Next.js LMS with role-based permissions and scoped MongoDB data.

Introduction

30 Second Summary

A school website can serve families plus staff from the same front door. One shared dashboard can easily show the wrong controls or the wrong student records.

In this project, you will secure the existing Noor Learning website with authenticated Director, Teacher, and Parent accounts. Server-side authorization will enforce role capabilities plus relationship-scoped records.

What You'll Build

Switch between Director, Teacher, and Parent accounts to watch Noor Learning reveal the right dashboard before a forbidden URL sends the account to a clear access-denied page.

By the end of this project, you'll have:

  • A secure Director workspace powered by Better Auth, where you can sign in before creating Teacher or Parent test accounts.
  • Role-aware navigation backed by server-side permission checks, so hidden sections stay blocked when someone types the URL directly.
  • Relationship-scoped views that show a Teacher only assigned-grade students plus a Parent only linked-child bulletins.
  • Secret Mission: add a second Teacher to prove that two accounts with the same role cannot see each other's assigned grade.

Are there any prerequisites?

You'll need the existing Noor Learning app built with Next.js, an active MongoDB Atlas connection URI, plus permission to add collections to its database. Your Mac also needs Node.js plus Visual Studio Code, with the exact compatibility check handled in Step 1.

Before We Start

This opening checkpoint defines Noor Learning's security boundaries before any hands-on work begins. Server-side authorization protects data even when someone types a hidden route directly.

Prepare the LMS Environment

Your existing LMS already runs on Next.js. The current Better Auth CLI has a stricter runtime requirement. It needs Node.js v22.12.0 or newer.

Exact package pins make the setup repeatable. Server-only environment variables keep your MongoDB Atlas connection details out of browser code.

In this step, get ready to:
  • Confirm the Better Auth CLI runtime inside the existing Noor Learning workspace.
  • Install the exact authentication and database dependencies.
  • Prepare the server-only environment for a successful LMS launch.
Verify Node.js in the LMS workspace

The version check must run in the same workspace where you install the project packages. This keeps every setup command tied to the existing Noor Learning app.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Click File in the menu bar.
  • Click Open Folder....
  • Select the existing Noor Learning project folder.
  • Click Open.

You should see the existing Noor Learning files in the Explorer sidebar. The integrated terminal now starts from this workspace.

  • Click View in the menu bar.
  • Click Terminal.
  • Check the installed Node.js version by running:
node -v

What does this command check?

The command prints the Node.js runtime used by this terminal. The Better Auth CLI requires v22.12.0 or newer.

✔️ I see v22.12.0 or newer

Good. Your runtime can run the current Better Auth CLI.

ⓧ I see an older version

Your existing runtime can run the LMS. The Better Auth CLI needs a newer release.

  • Visit the official Node.js download page.
  • Download the macOS PKG installer for a release that meets the required version.
  • Complete the installer.
  • Quit Visual Studio Code.
  • Reopen Visual Studio Code.
  • Return to the existing Noor Learning project.
  • Reopen the integrated terminal from the View menu.
  • Repeat the version check above. Confirm that the result is v22.12.0 or newer.

ⓧ Command not found

The terminal cannot currently find a Node.js installation. Install the required runtime before continuing.

  • Visit the official Node.js download page.
  • Download the macOS PKG installer for a release that meets the required version.
  • Complete the installer.
  • Quit Visual Studio Code.
  • Reopen Visual Studio Code.
  • Return to the existing Noor Learning project.
  • Reopen the integrated terminal from the View menu.
  • Repeat the version check above. Confirm that the result is v22.12.0 or newer.
Install dependencies and configure the environment

The authentication layer needs packages that agree on their APIs. Exact versions prevent a future package release from changing this project's behavior.

  • Install the pinned dependencies in the existing Noor Learning workspace by running:
npm install better-auth@1.7.2 @better-auth/mongo-adapter@1.7.2 mongodb@7.6.0 mongoose@9.9.4 --save-exact

What does this install?

  • Better Auth provides the authentication foundation used by the LMS.
  • The MongoDB adapter stores Better Auth records in the existing project database.
  • The MongoDB Node.js Driver supplies the native database client expected by the adapter.
  • Mongoose manages the LMS collections used for access profiles later in the project.

Package installation can take a minute while npm downloads the pinned releases. When the terminal prompt returns without an installation failure, the dependency setup is complete.

Why Better Auth Here?

Better Auth handles email and password authentication without adding custom password storage. Its Admin plugin also supplies the access-control primitives needed for Director, Teacher, Parent, and Pending roles.

The native MongoDB adapter stores authentication records. Mongoose remains responsible for Noor Learning data such as access profiles, students, and bulletins.

Dependency installation failed?

  • Confirm the integrated terminal is running inside the existing Noor Learning folder.
  • Repeat the Node.js check if the failure mentions the runtime or package compatibility.
  • Check your internet connection if npm cannot download a package.

Ask for targeted help with my pinned npm dependency installation. Share only sanitized error text with the NextWork community.

Your authentication secret protects session-related data. Keep the generated value private because anyone who obtains it could undermine that trust boundary.

  • Generate a high-entropy Better Auth secret by running:
openssl rand -base64 32

What does this command create?

OpenSSL prints a random Base64 value with enough entropy for the Better Auth secret. The output becomes the private value stored under BETTER_AUTH_SECRET.

  • Copy the generated value without sharing or screenshotting it.

Secret command did not run?

Confirm that the command was entered in the macOS terminal exactly as shown. Keep any generated secret out of screenshots or help messages.

Ask for help with generating a Better Auth secret safely.

  • Create .env.local.example in the top level of the existing Noor Learning folder using the file creation icon in the Explorer sidebar.

You should see .env.local.example appear beside the project's existing top-level files.

  • Paste the required environment variable template into .env.local.example using the code below:
MONGODB_URI=your-mongodb-atlas-uri
BETTER_AUTH_SECRET=your-generated-secret
BETTER_AUTH_URL=http://localhost:3000

What does this template document?

  • MONGODB_URI identifies the existing Atlas deployment used by Noor Learning.
  • BETTER_AUTH_SECRET identifies the private value Better Auth uses for secure operations.
  • BETTER_AUTH_URL sets the local authentication base URL to http://localhost:3000.
  • Save .env.local.example.
  • Confirm the saved file contains exactly three environment variable lines.

Environment template looks different?

Check the filename for the leading dot. Confirm each variable appears on its own line with no spaces around the equals sign.

Ask for help with checking my environment template.

✔️ Awesome, I've got everything!

Your shared environment template now matches the project artifact.

ⓧ I'd like to double check the full code

MONGODB_URI=your-mongodb-atlas-uri
BETTER_AUTH_SECRET=your-generated-secret
BETTER_AUTH_URL=http://localhost:3000

The example file documents required names without containing live credentials. Your private .env.local receives the real Atlas URI and generated secret.

  • Create the private environment file from the saved template by running:
cp .env.local.example .env.local

What does this command do?

The command copies the template into a new .env.local file. This gives you the same three variable names before you replace the private values.

  • Confirm .env.local now appears beside .env.local.example in the Explorer sidebar.

Private environment file missing?

Confirm that .env.local.example was saved before you copied it. Check that the terminal is still using the existing Noor Learning workspace.

Ask for help with creating my private environment file.

  • Open .env.local from the Explorer sidebar.
  • Replace your-mongodb-atlas-uri with your existing Atlas connection URI.
  • Save .env.local.

The private environment file now points to your existing Atlas deployment. Keep the URI out of screenshots because it may contain credentials.

  • Replace your-generated-secret with the secret you copied from the terminal.
  • Save .env.local.

The copied BETTER_AUTH_URL=http://localhost:3000 line already matches the local app address. Your private environment file now contains all three required values.

Start the existing LMS

The development server reads .env.local when it starts. The server keeps this terminal occupied until you stop it with Ctrl+C.

Before you run the app, do you expect the dependency setup to preserve the existing Noor Learning homepage?

  • Start the existing Next.js app by running:
npm run dev

What should the server report?

The development process should report that the app is ready. Its local address should point to http://localhost:3000.

Development server did not start?

  • Check that the terminal is running inside the existing Noor Learning folder.
  • Confirm the dependency installation completed without an error.
  • Check each variable name in .env.local for spelling differences.

Ask for help with starting my existing Noor Learning development server. Share only sanitized output with the NextWork community.

  • Use your usual browser to open a new tab.
  • Enter http://localhost:3000 in the address bar.
  • Press Enter.

You should see the existing Noor Learning site load as before. That confirms the pinned dependencies and private environment configuration have preserved the working LMS.

Your LMS environment is ready for secure authentication work. Next, you will connect Better Auth to MongoDB and sign in with the Director account.

Add Director Authentication

Your Noor Learning app now has the pinned packages plus the server-only environment values that authentication needs. The existing site is still running in your local development environment.

The dashboard needs a server-validated identity before any role or menu can be trusted. In this step, you will connect Better Auth to MongoDB Atlas and sign in as the Director.

In this step, get ready to:
  • Connect Better Auth to the existing MongoDB Atlas database.
  • Add server-side session validation to the Director sign-in flow.
  • Create the Director account.
Build the authentication foundation

Authentication records need a shared database connection. Reusing one MongoClient also prevents development reloads from creating unnecessary connection pools.

  • Create lib/mongodb.ts inside the existing Noor Learning project using the file controls in Visual Studio Code.
  • Add the shared database connection to lib/mongodb.ts by pasting this code:
import { MongoClient } from "mongodb";

const uri = process.env.MONGODB_URI;

if (!uri) {
  throw new Error("Please add your Mongo URI to .env.local");
}

const globalForMongo = globalThis as typeof globalThis & {
  noorMongoClient?: MongoClient;
};

export const mongoClient =
  globalForMongo.noorMongoClient ?? new MongoClient(uri);

globalForMongo.noorMongoClient = mongoClient;

export const mongoDb = mongoClient.db("noor_lms");

What does this connection provide?

  • The uri constant reads the Atlas connection string from the server environment.
  • The global cache lets development reloads reuse the same MongoClient.
  • The mongoDb export selects the project-defined noor_lms database.
  • Save lib/mongodb.ts.
  • Confirm that Visual Studio Code lists mongodb.ts inside the lib folder.

Seeing a database configuration error?

Confirm that the existing .env.local uses the exact name MONGODB_URI. Keep the connection value private.

Check that mongoDb selects noor_lms exactly. Help me diagnose the MongoDB connection without exposing my URI.

Role-based access control turns an authenticated identity into specific capabilities. Noor Learning uses separate role definitions so a Director can manage the system while Teacher and Parent accounts receive narrower access.

  • Create lib/permissions.ts inside the existing lib folder using Visual Studio Code.
  • Define the application resources in lib/permissions.ts by pasting this code:
import { createAccessControl } from "better-auth/plugins/access";
import {
  adminAc,
  defaultStatements,
} from "better-auth/plugins/admin/access";

export const statement = {
  ...defaultStatements,
  student: ["read", "manage"],
  lesson: ["read", "create", "update"],
  remark: ["read", "create", "update"],
  bulletin: ["read", "download", "manage"],
  content: ["read", "create", "update", "delete"],
} as const;

export const ac = createAccessControl(statement);

How does the capability map work?

  • The statement object adds Noor Learning resources to the administrator defaults.
  • Each resource lists the actions that a configured role can receive.
  • The ac export converts those statements into reusable role definitions.
  • Save lib/permissions.ts.
  • Confirm that the file contains the statement export.

Permission imports not resolving?

Check that both import paths begin with better-auth/plugins. Confirm that the pinned Better Auth package remains installed in the project.

Make sure the file is named permissions.ts. Help me diagnose the permission imports.

  • Add the role definitions below export const ac = createAccessControl(statement); by pasting this code:
export const director = ac.newRole({
  ...adminAc.statements,
  student: ["read", "manage"],
  lesson: ["read", "create", "update"],
  remark: ["read", "create", "update"],
  bulletin: ["read", "download", "manage"],
  content: ["read", "create", "update", "delete"],
});

export const teacher = ac.newRole({
  student: ["read"],
  lesson: ["read", "create", "update"],
  remark: ["read", "create", "update"],
  content: ["read", "create", "update"],
});

export const parent = ac.newRole({
  bulletin: ["read", "download"],
});

export const pending = ac.newRole({});

export const roles = {
  admin: director,
  teacher,
  parent,
  pending,
};

What do these roles represent?

  • The director role combines administrator capabilities with every Noor Learning capability.
  • The teacher role receives the capabilities needed for teaching work.
  • The parent role receives bulletin capabilities.
  • The pending role grants no capabilities until the account receives an approved role.
  • Save lib/permissions.ts.
  • Confirm that the final roles map connects admin to director.

Role definitions showing an error?

Confirm that director spreads adminAc.statements before the application capabilities. Check every role name against the final roles map.

Help me inspect the custom role definitions.

The shared authentication instance brings the database adapter together with the capability map. Public email sign-up stays disabled so Noor Learning controls which accounts can enter the system.

  • Create lib/auth.ts inside the existing lib folder using Visual Studio Code.
  • Add the authentication imports to lib/auth.ts by pasting this code:
import { betterAuth } from "better-auth";
import { mongodbAdapter } from "better-auth/adapters/mongodb";
import { nextCookies } from "better-auth/next-js";
import { admin } from "better-auth/plugins";
import { mongoClient, mongoDb } from "@/lib/mongodb";
import { ac, roles } from "@/lib/permissions";

Why are these imports needed?

  • The database imports connect authentication to the shared MongoDB client.
  • The permission imports supply the custom role model.
  • The cookie integration allows server actions to preserve an authenticated session.
  • Save lib/auth.ts.
  • Confirm that Visual Studio Code lists auth.ts beside mongodb.ts.

Authentication imports showing an error?

Check that the two local imports begin with @/lib/. Confirm that mongodb.ts and permissions.ts are saved.

Help me trace an unresolved authentication import.

  • Configure the shared authentication instance below the imports by pasting this code:
export const auth = betterAuth({
  appName: "Noor Learning",
  database: mongodbAdapter(mongoDb, {
    client: mongoClient,
  }),
  account: {
    identityStrategy: "provider-id",
  },
  emailAndPassword: {
    enabled: true,
    disableSignUp: true,
  },
  advanced: {
    database: {
      joins: true,
    },
  },
  plugins: [
    admin({
      ac,
      roles,
      adminRoles: ["admin"],
      defaultRole: "pending",
    }),
    nextCookies(),
  ],
});

How is authentication configured?

  • The MongoDB adapter stores identities plus sessions in noor_lms.
  • Email plus password authentication is enabled for accounts created by Noor Learning.
  • Public sign-up is disabled.
  • The Admin plugin maps the stored admin role to the Director capabilities.
  • The nextCookies() plugin stays last so server actions can update cookies.
  • Save lib/auth.ts.
  • Confirm that nextCookies() is the final entry in the plugins array.

Authentication configuration not compiling?

Confirm that mongodbAdapter receives mongoDb with the shared mongoClient.

Check that adminRoles contains admin. Help me diagnose the Better Auth configuration.

A Next.js route handler exposes the authentication server to the app. The catch-all route forwards supported authentication requests to the shared auth instance.

  • Create app/api/auth/[...all]/route.ts inside the existing app folder using Visual Studio Code.
  • Expose the authentication handlers in route.ts by pasting this code:
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";

export const { GET, POST } = toNextJsHandler(auth);

What does this route expose?

The handler converts the shared authentication instance into GET plus POST route exports. Authentication requests can now reach Better Auth through the App Router.

  • Save app/api/auth/[...all]/route.ts.
  • Check the running development terminal for the next compilation result.

You should see the route compile without a TypeScript error. That result proves the database connection plus authentication configuration can load together.

Route handler not compiling?

Check every folder segment in app/api/auth/[...all]/route.ts. Confirm that the route imports auth from @/lib/auth.

Help me trace the authentication route failure.

Add the server sign-in flow

Protected pages need a fresh server check before they trust account data. The authorization helpers read the current request headers so every protected request receives an authoritative session check.

  • Create lib/authorization.ts inside the existing lib folder using Visual Studio Code.
  • Add the session guard to lib/authorization.ts by pasting this code:
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";

export async function requireSession() {
  const requestHeaders = await headers();
  const session = await auth.api.getSession({
    headers: requestHeaders,
  });

  if (!session) {
    redirect("/login");
  }

  return session;
}

How does the session guard work?

  • The helper sends the current request headers to the server session API.
  • A missing session redirects the request to /login.
  • A valid session returns trusted user information to the protected page.
  • Save lib/authorization.ts.
  • Confirm that Visual Studio Code lists the requireSession export in the file.

Session guard showing an error?

Confirm that the helper awaits headers() before calling the session API. Check that the login path begins with a forward slash.

Help me diagnose the server session guard.

  • Add the capability guard below requireSession() by pasting this code:
export async function requirePermission(
  permissions: Record<string, string[]>,
) {
  const requestHeaders = await headers();
  const session = await auth.api.getSession({
    headers: requestHeaders,
  });

  if (!session) {
    redirect("/login");
  }

  const result = await auth.api.userHasPermission({
    headers: requestHeaders,
    body: { permissions } as never,
  });

  if (!result.success) {
    redirect("/unauthorized");
  }

  return session;
}

How does the capability guard work?

  • The helper validates the current session before checking a capability.
  • The permission API compares the requested actions with the account's configured role.
  • A denied request redirects to /unauthorized.
  • An approved request returns the trusted session.
  • Save lib/authorization.ts.
  • Confirm that the file exports requirePermission below requireSession.

Capability guard showing an error?

Check that both authentication API calls receive requestHeaders.

Confirm that the permission result is checked through result.success. Help me diagnose the server capability guard.

The login form sends its credentials to a server action. A successful sign-in creates the session cookie before redirecting the authenticated request to the dashboard.

  • Create app/login/actions.ts inside the existing app folder using Visual Studio Code.
  • Add the server sign-in action to actions.ts by pasting this code:
"use server";

import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";

export async function signInAction(formData: FormData) {
  const email = String(formData.get("email") ?? "");
  const password = String(formData.get("password") ?? "");

  await auth.api.signInEmail({
    body: {
      email,
      password,
    },
  });

  redirect("/dashboard");
}

What does the sign-in action do?

  • The server action reads the submitted email from FormData.
  • The server action reads the submitted password from the same form data.
  • The email sign-in API validates those credentials against the stored account.
  • A successful sign-in redirects the authenticated request to /dashboard.
  • Save app/login/actions.ts.
  • Confirm that the first line contains "use server";.

Sign-in action showing an error?

Confirm that the form keys remain email plus password. Check that the action imports the shared auth instance.

Help me inspect the Director sign-in action.

  • Create app/login/page.tsx beside actions.ts using Visual Studio Code.
  • Add the sign-in form to page.tsx by pasting this code:
import { signInAction } from "./actions";

export default function LoginPage() {
  return (
    <main>
      <h1>Noor Learning Sign In</h1>
      <form action={signInAction}>
        <label>
          Email
          <input name="email" type="email" required />
        </label>
        <label>
          Password
          <input name="password" type="password" minLength={8} required />
        </label>
        <button type="submit">Sign in</button>
      </form>
    </main>
  );
}

How does the form reach the server?

  • The form connects to signInAction through its action property.
  • The input names match the keys read by the server action.
  • The password field requires at least eight characters before submission.
  • Save app/login/page.tsx.
  • Return to the browser from the previous step.
  • Visit http://localhost:3000/login.

You should see the Noor Learning Sign In heading above the email field plus the password field. The visible form proves the route can load the server action.

Login form not appearing?

Confirm that page.tsx sits directly inside app/login. Check the development terminal for an import problem involving ./actions.

Help me diagnose the login page.

The added files should now match the complete authentication foundation. Use the reference below to compare each file before creating the Director account.

✔️ Awesome, I've got everything!

Your authentication files are saved. Continue to the Director account command.

ⓧ I'd like to double check the full code

Your existing .env.local.example should still contain this template.

MONGODB_URI=your-mongodb-atlas-uri
BETTER_AUTH_SECRET=your-generated-secret
BETTER_AUTH_URL=http://localhost:3000

What should match?

The template documents the required environment variable names without exposing the values stored in .env.local.

Your complete lib/mongodb.ts should look like this.

import { MongoClient } from "mongodb";

const uri = process.env.MONGODB_URI;

if (!uri) {
  throw new Error("Please add your Mongo URI to .env.local");
}

const globalForMongo = globalThis as typeof globalThis & {
  noorMongoClient?: MongoClient;
};

export const mongoClient =
  globalForMongo.noorMongoClient ?? new MongoClient(uri);

globalForMongo.noorMongoClient = mongoClient;

export const mongoDb = mongoClient.db("noor_lms");

What should match?

This file exports the shared mongoClient plus the selected mongoDb database.

Your complete lib/permissions.ts should look like this.

import { createAccessControl } from "better-auth/plugins/access";
import {
  adminAc,
  defaultStatements,
} from "better-auth/plugins/admin/access";

export const statement = {
  ...defaultStatements,
  student: ["read", "manage"],
  lesson: ["read", "create", "update"],
  remark: ["read", "create", "update"],
  bulletin: ["read", "download", "manage"],
  content: ["read", "create", "update", "delete"],
} as const;

export const ac = createAccessControl(statement);

export const director = ac.newRole({
  ...adminAc.statements,
  student: ["read", "manage"],
  lesson: ["read", "create", "update"],
  remark: ["read", "create", "update"],
  bulletin: ["read", "download", "manage"],
  content: ["read", "create", "update", "delete"],
});

export const teacher = ac.newRole({
  student: ["read"],
  lesson: ["read", "create", "update"],
  remark: ["read", "create", "update"],
  content: ["read", "create", "update"],
});

export const parent = ac.newRole({
  bulletin: ["read", "download"],
});

export const pending = ac.newRole({});

export const roles = {
  admin: director,
  teacher,
  parent,
  pending,
};

What should match?

This file defines the complete capability map plus all four application roles.

Your complete lib/auth.ts should look like this.

import { betterAuth } from "better-auth";
import { mongodbAdapter } from "better-auth/adapters/mongodb";
import { nextCookies } from "better-auth/next-js";
import { admin } from "better-auth/plugins";
import { mongoClient, mongoDb } from "@/lib/mongodb";
import { ac, roles } from "@/lib/permissions";

export const auth = betterAuth({
  appName: "Noor Learning",
  database: mongodbAdapter(mongoDb, {
    client: mongoClient,
  }),
  account: {
    identityStrategy: "provider-id",
  },
  emailAndPassword: {
    enabled: true,
    disableSignUp: true,
  },
  advanced: {
    database: {
      joins: true,
    },
  },
  plugins: [
    admin({
      ac,
      roles,
      adminRoles: ["admin"],
      defaultRole: "pending",
    }),
    nextCookies(),
  ],
});

What should match?

This file exports the MongoDB-backed auth instance with public sign-up disabled.

Your complete app/api/auth/[...all]/route.ts should look like this.

import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";

export const { GET, POST } = toNextJsHandler(auth);

What should match?

This route exports the Better Auth request handlers for the App Router.

Your complete lib/authorization.ts should look like this.

import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";

export async function requireSession() {
  const requestHeaders = await headers();
  const session = await auth.api.getSession({
    headers: requestHeaders,
  });

  if (!session) {
    redirect("/login");
  }

  return session;
}

export async function requirePermission(
  permissions: Record<string, string[]>,
) {
  const requestHeaders = await headers();
  const session = await auth.api.getSession({
    headers: requestHeaders,
  });

  if (!session) {
    redirect("/login");
  }

  const result = await auth.api.userHasPermission({
    headers: requestHeaders,
    body: { permissions } as never,
  });

  if (!result.success) {
    redirect("/unauthorized");
  }

  return session;
}

What should match?

This file exports the server-side session guard plus the capability guard.

Your complete app/login/actions.ts should look like this.

"use server";

import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";

export async function signInAction(formData: FormData) {
  const email = String(formData.get("email") ?? "");
  const password = String(formData.get("password") ?? "");

  await auth.api.signInEmail({
    body: {
      email,
      password,
    },
  });

  redirect("/dashboard");
}

What should match?

This server action validates the submitted credentials before redirecting to the dashboard.

Your complete app/login/page.tsx should look like this.

import { signInAction } from "./actions";

export default function LoginPage() {
  return (
    <main>
      <h1>Noor Learning Sign In</h1>
      <form action={signInAction}>
        <label>
          Email
          <input name="email" type="email" required />
        </label>
        <label>
          Password
          <input name="password" type="password" minLength={8} required />
        </label>
        <button type="submit">Sign in</button>
      </form>
    </main>
  );
}

What should match?

This page connects the visible sign-in form to signInAction.

Create and verify the Director

The first Director account uses a test password in your local environment. Choose a password that you have never used for a real account.

  • Create a second terminal panel from the terminal controls in Visual Studio Code.
  • Keep the existing development server running in its original terminal panel.
  • Expect the account command to prompt for a test password.
  • Create the Director account from the Noor Learning project by running this command:
npx auth@latest create-admin --email admin@example.com --name "Admin" --role admin

What does this command create?

The command creates admin@example.com with the name Admin plus the stored role admin. The custom role map presents this account as the Director.

The MongoDB adapter writes the authentication records directly. No schema generation or migration is required.

  • Enter your chosen test password when prompted.
  • Confirm that the command completes without an account creation error.

Director account command failing?

Confirm that this terminal panel uses the existing Noor Learning project. The command needs access to the server environment configuration from earlier.

Keep your Atlas URI plus your test password out of any help request. Help me diagnose the Director account command safely.

  • Return to http://localhost:3000/login in the browser.
  • Enter admin@example.com in the Email field.
  • Enter your test password in the Password field.

Before you submit the form, do you expect the server to accept this account or return you to the login page?

  • Click Sign in.

You should reach /dashboard and see Admin, admin@example.com, plus the Director label. This dashboard proves the server validated a MongoDB-backed session.

  • Refresh the dashboard in your browser.

You should remain on the authenticated dashboard with the same Director details. That is the identity layer working across requests.

Sign-in not reaching the dashboard?

Confirm that the email is exactly admin@example.com. Re-enter the test password selected during account creation.

Check the development terminal for a database connection or server action problem. Help me diagnose the Director sign-in flow.

Your Director now has a server-validated identity plus a capability model. Next, you can create the Teacher and Parent accounts that receive narrower access.

Create Roles and Test Accounts

Your Better Auth session already proves that Noor Learning can identify the signed-in Director. The dashboard now needs to represent the smaller set of tasks available to each account.

The role definitions from earlier provide explicit capabilities for Directors, Teachers, Parents, and Pending users. This step connects those roles to dashboard navigation and relationship metadata.

In this step, get ready to:
  • Add the LMS data layer for access profiles, students, and bulletins.
  • Build role-aware navigation and Director-only account controls.
  • Create Teacher and Parent test accounts before exposing the direct-route security gap.
Build the LMS data layer

Authentication records describe who can sign in. An access profile stores the relationships that narrow what a Teacher or Parent can receive.

The existing native MongoDB Atlas connection continues to support authentication. Mongoose manages the LMS collections in the same noor_lms database.

  • Select the lib folder in the Visual Studio Code Explorer sidebar.
  • Create lms-data.ts inside the lib folder.
  • Build the LMS data layer using the complete file reference below:

✔️ Awesome, I've got everything!

Your LMS data layer now has the models and query helpers needed by the dashboard.

ⓧ I'd like to double check the full code

import mongoose from "mongoose";

const uri = process.env.MONGODB_URI;

if (!uri) {
  throw new Error("Please define the MONGODB_URI environment variable");
}

const globalForMongoose = globalThis as typeof globalThis & {
  noorMongoosePromise?: ReturnType<typeof mongoose.connect>;
};

async function connectMongoose() {
  globalForMongoose.noorMongoosePromise ??= mongoose.connect(uri, {
    dbName: "noor_lms",
  });
  return globalForMongoose.noorMongoosePromise;
}

const accessProfileSchema = new mongoose.Schema(
  {
    email: { type: String, required: true, unique: true, lowercase: true },
    role: {
      type: String,
      required: true,
      enum: ["teacher", "parent"],
    },
    field: { type: String, default: "" },
    assignedGradeIds: { type: [String], default: [] },
    childIds: { type: [String], default: [] },
  },
  { timestamps: true },
);

const studentSchema = new mongoose.Schema(
  {
    studentId: { type: String, required: true, unique: true },
    name: { type: String, required: true },
    gradeId: { type: String, required: true, index: true },
  },
  { timestamps: true },
);

const bulletinSchema = new mongoose.Schema(
  {
    bulletinId: { type: String, required: true, unique: true },
    studentId: { type: String, required: true, index: true },
    title: { type: String, required: true },
    downloadLabel: { type: String, required: true },
  },
  { timestamps: true },
);

const AccessProfile = mongoose.model(
  "AccessProfile",
  accessProfileSchema,
  "access_profiles",
  { overwriteModels: true },
);

const Student = mongoose.model("Student", studentSchema, "students", {
  overwriteModels: true,
});

const Bulletin = mongoose.model("Bulletin", bulletinSchema, "bulletins", {
  overwriteModels: true,
});

function cleanList(value: string) {
  return value
    .split(",")
    .map((item) => item.trim())
    .filter(Boolean);
}

export async function saveAccessProfile(input: {
  email: string;
  role: "teacher" | "parent";
  field: string;
  assignedGradeIds: string;
  childIds: string;
}) {
  await connectMongoose();
  await AccessProfile.findOneAndUpdate(
    { email: input.email.toLowerCase() },
    {
      email: input.email.toLowerCase(),
      role: input.role,
      field: input.field.trim(),
      assignedGradeIds: cleanList(input.assignedGradeIds),
      childIds: cleanList(input.childIds),
    },
    {
      upsert: true,
      returnDocument: "after",
      runValidators: true,
    },
  );
}

export async function seedDemoData() {
  await connectMongoose();

  await Student.findOneAndUpdate(
    { studentId: "student-amina" },
    { studentId: "student-amina", name: "Amina Yusuf", gradeId: "grade-7" },
    { upsert: true, returnDocument: "after", runValidators: true },
  );

  await Student.findOneAndUpdate(
    { studentId: "student-yusuf" },
    { studentId: "student-yusuf", name: "Yusuf Ali", gradeId: "grade-8" },
    { upsert: true, returnDocument: "after", runValidators: true },
  );

  await Bulletin.findOneAndUpdate(
    { bulletinId: "bulletin-amina-term-1" },
    {
      bulletinId: "bulletin-amina-term-1",
      studentId: "student-amina",
      title: "Amina Term 1 Bulletin",
      downloadLabel: "Download Amina's bulletin",
    },
    { upsert: true, returnDocument: "after", runValidators: true },
  );

  await Bulletin.findOneAndUpdate(
    { bulletinId: "bulletin-yusuf-term-1" },
    {
      bulletinId: "bulletin-yusuf-term-1",
      studentId: "student-yusuf",
      title: "Yusuf Term 1 Bulletin",
      downloadLabel: "Download Yusuf's bulletin",
    },
    { upsert: true, returnDocument: "after", runValidators: true },
  );
}

export async function listStudentsForUser(email: string, role: string) {
  await connectMongoose();

  if (role === "admin") {
    return Student.find({}).sort({ name: 1 }).lean();
  }

  const profile = await AccessProfile.findOne({
    email: email.toLowerCase(),
  }).lean();

  if (!profile) {
    return [];
  }

  if (role === "teacher") {
    return Student.find({
      gradeId: { $in: profile.assignedGradeIds },
    })
      .sort({ name: 1 })
      .lean();
  }

  return [];
}

export async function listBulletinsForUser(email: string, role: string) {
  await connectMongoose();

  if (role === "admin") {
    return Bulletin.find({}).sort({ title: 1 }).lean();
  }

  const profile = await AccessProfile.findOne({
    email: email.toLowerCase(),
  }).lean();

  if (!profile || role !== "parent") {
    return [];
  }

  return Bulletin.find({
    studentId: { $in: profile.childIds },
  })
    .sort({ title: 1 })
    .lean();
}

What does this data layer do?

  • The shared connection promise prevents repeated Mongoose connections during development reloads.
  • The AccessProfile model stores each account's role and relationship metadata in access_profiles.
  • The cleanList() helper converts comma-separated form values into arrays that MongoDB can query.
  • The student and bulletin helpers prepare the data layer for relationship-scoped views.
  • Save lib/lms-data.ts.
  • Confirm lms-data.ts appears beneath lib in the Explorer sidebar.
  • Return to the Noor Learning dashboard from earlier.
  • Refresh the page and confirm the authenticated Director view still loads.

Seeing a Mongoose or environment error?

Check that the import uses mongoose and that MONGODB_URI remains defined in .env.local.

Confirm the database name is noor_lms in connectMongoose().

Help me diagnose my LMS data layer.

Build the role-aware dashboard

The dashboard needs Server Actions for account creation and sign-out. It also needs server-rendered navigation based on the authenticated role.

The Director form creates the authentication account first. It then writes the matching LMS relationships through saveAccessProfile().

  • Create actions.ts inside the app/dashboard folder.
  • Add the dashboard actions using the complete file reference below:

✔️ Awesome, I've got everything!

Your dashboard now has protected actions for account creation, demo data, and sign-out.

ⓧ I'd like to double check the full code

"use server";

import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { requirePermission } from "@/lib/authorization";
import { saveAccessProfile, seedDemoData } from "@/lib/lms-data";

export async function createLmsUserAction(formData: FormData) {
  await requirePermission({ user: ["create"] });

  const role = String(formData.get("role") ?? "");
  if (role !== "teacher" && role !== "parent") {
    throw new Error("Role must be teacher or parent");
  }

  const email = String(formData.get("email") ?? "").toLowerCase();
  const password = String(formData.get("password") ?? "");
  const name = String(formData.get("name") ?? "");

  await auth.api.createUser({
    headers: await headers(),
    body: {
      email,
      password,
      name,
      role,
    },
  });

  await saveAccessProfile({
    email,
    role,
    field: String(formData.get("field") ?? ""),
    assignedGradeIds: String(formData.get("assignedGradeIds") ?? ""),
    childIds: String(formData.get("childIds") ?? ""),
  });

  redirect("/dashboard");
}

export async function seedDemoDataAction() {
  await requirePermission({ student: ["manage"] });
  await seedDemoData();
  redirect("/dashboard");
}

export async function signOutAction() {
  await auth.api.signOut({
    headers: await headers(),
  });
  redirect("/login");
}

How are these actions protected?

  • The account action requires the authenticated user to have the user resource's create capability.
  • The action accepts only teacher or parent as the new account role.
  • The same email connects the Better Auth user to its LMS access profile.
  • The sign-out action ends the current session before returning the browser to /login.
  • Save app/dashboard/actions.ts.
  • Refresh the dashboard and confirm the Director session remains active.
  • Create layout.tsx inside the app/dashboard folder.
  • Add the role-aware dashboard layout using the complete file reference below:

✔️ Awesome, I've got everything!

Your dashboard layout now chooses links from the authenticated account's stored role.

ⓧ I'd like to double check the full code

import Link from "next/link";
import { requireSession } from "@/lib/authorization";
import { signOutAction } from "./actions";

const navByRole = {
  admin: [
    ["Home", "/dashboard"],
    ["Enrollments", "/dashboard/enrollments"],
    ["Students", "/dashboard/students"],
    ["Lessons", "/dashboard/lessons"],
    ["Remarks", "/dashboard/remarks"],
    ["Content", "/dashboard/content"],
    ["Bulletins", "/dashboard/bulletins"],
  ],
  teacher: [
    ["Home", "/dashboard"],
    ["Students", "/dashboard/students"],
    ["Lessons", "/dashboard/lessons"],
    ["Remarks", "/dashboard/remarks"],
    ["Content", "/dashboard/content"],
  ],
  parent: [
    ["Home", "/dashboard"],
    ["Bulletins", "/dashboard/bulletins"],
  ],
  pending: [["Home", "/dashboard"]],
} as const;

const roleLabels = {
  admin: "Director",
  teacher: "Teacher",
  parent: "Parent",
  pending: "Pending",
} as const;

export default async function DashboardLayout({ children }: { children: any }) {
  const session = await requireSession();
  const storedRole = String(session.user.role ?? "pending").split(",")[0];
  const role = storedRole in navByRole ? storedRole : "pending";
  const links = navByRole[role as keyof typeof navByRole];
  const label = roleLabels[role as keyof typeof roleLabels];

  return (
    <div>
      <header>
        <strong>Noor Learning</strong>
        <span> {session.user.name} | {label}</span>
        <form action={signOutAction}>
          <button type="submit">Sign out</button>
        </form>
      </header>
      <nav>
        {links.map(([name, href]) => (
          <Link key={href} href={href}>
            {name}{" "}
          </Link>
        ))}
      </nav>
      <main>{children}</main>
    </div>
  );
}

How does the menu choose its links?

The layout validates the stored role against navByRole. An unknown value falls back to the restricted pending menu.

The visible label comes from roleLabels. This lets the database keep the internal role admin while the interface displays Director.

  • Save app/dashboard/layout.tsx.
  • Refresh the dashboard and confirm the header displays Admin | Director.
  • Confirm the Director menu includes Enrollments, Students, and Bulletins.
  • Create page.tsx inside the app/dashboard folder.
  • Add the Director dashboard controls using the complete file reference below:

✔️ Awesome, I've got everything!

The Director dashboard now contains account and demo-data controls.

ⓧ I'd like to double check the full code

import { requireSession } from "@/lib/authorization";
import { createLmsUserAction, seedDemoDataAction } from "./actions";

export default async function DashboardPage() {
  const session = await requireSession();
  const role = String(session.user.role ?? "pending").split(",")[0];

  return (
    <section>
      <h1>Secure Dashboard</h1>
      <p>Signed in as {session.user.email}</p>
      <p>Stored role: {role}</p>

      {role === "admin" ? (
        <>
          <h2>Create a Teacher or Parent</h2>
          <form action={createLmsUserAction}>
            <input name="name" placeholder="Full name" required />
            <input name="email" type="email" placeholder="Email" required />
            <input
              name="password"
              type="password"
              placeholder="Temporary password"
              minLength={8}
              required
            />
            <select name="role" required>
              <option value="teacher">Teacher</option>
              <option value="parent">Parent</option>
            </select>
            <input name="field" placeholder="Field, for example Science" />
            <input
              name="assignedGradeIds"
              placeholder="Assigned grades, for example grade-7"
            />
            <input
              name="childIds"
              placeholder="Child IDs, for example student-amina"
            />
            <button type="submit">Create account</button>
          </form>

          <h2>Demo Records</h2>
          <form action={seedDemoDataAction}>
            <button type="submit">Seed demo students and bulletins</button>
          </form>
        </>
      ) : (
        <p>Your navigation and server permissions come from this account.</p>
      )}
    </section>
  );
}

Why check the role again?

The page uses the authenticated session to decide whether to render Director controls. Teacher and Parent accounts receive a short account summary instead.

The account action performs its own capability check on the server. That check protects account creation even when the form is absent.

  • Save app/dashboard/page.tsx.
  • Refresh the dashboard and confirm you see Create a Teacher or Parent.
  • Confirm you also see the Seed demo students and bulletins button.
  • Create a folder named [section] inside app/dashboard.
  • Create page.tsx inside app/dashboard/[section].
  • Add the temporary section route using the complete file reference below:

✔️ Awesome, I've got everything!

The dashboard now resolves each supported section through one dynamic route.

ⓧ I'd like to double check the full code

import { redirect } from "next/navigation";

const permissionBySection: Record<string, Record<string, string[]>> = {
  enrollments: { student: ["manage"] },
  students: { student: ["read"] },
  lessons: { lesson: ["read"] },
  remarks: { remark: ["read"] },
  content: { content: ["read"] },
  bulletins: { bulletin: ["read"] },
};

const titleBySection: Record<string, string> = {
  enrollments: "Enrollment Management",
  students: "Students",
  lessons: "Lessons",
  remarks: "Remarks",
  content: "Content Library",
  bulletins: "Bulletins",
};

export default async function SectionPage({
  params,
}: {
  params: Promise<{ section: string }>;
}) {
  const { section } = await params;
  const permissions = permissionBySection[section];

  if (!permissions) {
    redirect("/dashboard");
  }

  return (
    <section>
      <h1>{titleBySection[section]}</h1>

      {section === "enrollments" ? (
        <p>Director-only enrollment workspace unlocked.</p>
      ) : null}

      <p>Your role has permission to open this section.</p>
    </section>
  );
}

What does the dynamic route provide?

The folder segment [section] captures the final part of the dashboard URL. titleBySection turns that value into the page heading.

The capability map records the permission associated with each section. The route currently uses the map only to recognize supported sections.

  • Save app/dashboard/[section]/page.tsx.
  • Select Students in the Director menu.
  • Confirm the page displays Students and Your role has permission to open this section..
  • Return to the dashboard using Home.

Dashboard failing to compile?

Confirm the dashboard files sit under app/dashboard and that the dynamic folder includes both square brackets in its name.

Check that createLmsUserAction and signOutAction use the same spelling in their imports and exports.

Help me fix my role-aware dashboard.

Create and test the role accounts

Each test account needs an authentication role and a matching access profile. The profile ties the Teacher to grade-7 or the Parent to student-amina.

Use test-only passwords for these accounts. Never reuse a password from a real account.

  • Return to the Director dashboard from earlier.
  • Enter Teacher in the Full name field.
  • Enter teacher@noor.local in the Email field.
  • Enter a test-only password with at least eight characters in the Temporary password field.
  • Select Teacher in the role field.
  • Enter Science in the Field, for example Science field.
  • Enter grade-7 in the Assigned grades, for example grade-7 field.
  • Click Create account.

You return to the Director dashboard. The Teacher now has a Better Auth account and an access_profiles record for Grade 7.

  • Enter Parent in the Full name field.
  • Enter parent@noor.local in the Email field.
  • Enter a test-only password with at least eight characters in the Temporary password field.
  • Select Parent in the role field.
  • Enter student-amina in the Child IDs, for example student-amina field.
  • Click Create account.

The Parent now has a matching profile linked to Amina. The two accounts share the same sign-in flow while carrying different relationships.

  • Click Sign out in the dashboard header.
  • Enter parent@noor.local in the login email field.
  • Enter the Parent test password in the password field.
  • Click Sign in.
  • Confirm the header displays Parent | Parent.
  • Confirm the menu contains Home and Bulletins.

Good progress. Noor Learning now reads the Parent role from a server-validated session and renders the smaller menu assigned to that role.

  • Click Sign out in the dashboard header.
  • Enter teacher@noor.local in the login email field.
  • Enter the Teacher test password in the password field.
  • Click Sign in.
  • Confirm the header displays Teacher | Teacher.
  • Confirm the menu omits Enrollments.

Before you check the hidden route, do you think removing its menu link also prevents the Teacher from loading its URL?

  • Enter http://localhost:3000/dashboard/enrollments in the browser address bar.
  • Load the address by pressing Enter.

You see Enrollment Management and Director-only enrollment workspace unlocked. while signed in as the Teacher. This shortfall is intentional.

The hidden route is still reachable

Role-aware navigation controls what the interface advertises. The direct request still reaches the temporary page because that route has not performed its capability check.

This proves that menu visibility cannot enforce access by itself. The server must make the final authorization decision for every protected request.

Unable to create or test an account?

Confirm the Director is signed in before submitting the account form. Only that role has the capability used by createLmsUserAction().

Use a unique email for each account. Reusing an existing email prevents a second user record from being created.

Help me troubleshoot my Noor Learning test account.

Your Director can now create relationship-aware test accounts. Next, you will enforce each capability on the server so a hidden URL cannot bypass the menu.

Enforce Server Permissions

Your role-aware menu now guides each account toward the right sections. However, the Teacher can still type the hidden Enrollments URL directly.

Your existing requirePermission() helper applies server-side authorization before a section renders. It validates the session through Better Auth.

In this step, get ready to:
  • Reproduce the direct-route gap with the existing Teacher account.
  • Create a clear destination for denied requests.
  • Enforce section capabilities on the server.
Reproduce the direct-route gap

Navigation controls what the Teacher sees in the menu. The current section route still renders before checking the account's capabilities.

  • Switch back to the running Noor Learning app from earlier.
  • Check the role label in the dashboard header.

The header should show Teacher.

Seeing Another Role?

  • Click Sign out in the dashboard header.
  • Sign in as teacher@noor.local with the test password you chose earlier.

Need help switching accounts?

Before you enter the hidden route, do you think the server checks the Teacher's permission?

  • Enter http://localhost:3000/dashboard/enrollments in the browser address bar.

The Enrollment Management placeholder opens. You have reproduced the security gap that the server check must close.

Give denied requests a destination

A rejected request needs a clear result. The unauthorized page explains the denial without revealing protected section content.

  • Create app/unauthorized/page.tsx from the Explorer sidebar in Visual Studio Code.
  • Fill app/unauthorized/page.tsx by pasting the following code:
import Link from "next/link";

export default function UnauthorizedPage() {
  return (
    <main>
      <h1>Access denied</h1>
      <p>Your account does not have permission to open this section.</p>
      <Link href="/dashboard">Return to your dashboard</Link>
    </main>
  );
}

What Does This Page Do?

  • The heading makes the denied state visible to the signed-in account.
  • The explanation confirms that the account lacks permission for the requested section.
  • The Link component provides a safe route back to /dashboard.
  • Save app/unauthorized/page.tsx.
  • Visit http://localhost:3000/unauthorized in the browser.

You should see Access denied with a link back to the dashboard. Denied requests now have a clear destination.

Unauthorized Page Not Loading?

  • Confirm the file is named page.tsx inside app/unauthorized.
  • Check that the import uses next/link.
  • Save the file before refreshing the browser.

Why is my unauthorized page missing?

✔️ Awesome, I've got everything!

Your denied-access page is saved. It gives protected routes a safe redirect destination.

ⓧ I'd like to double check the full code

import Link from "next/link";

export default function UnauthorizedPage() {
  return (
    <main>
      <h1>Access denied</h1>
      <p>Your account does not have permission to open this section.</p>
      <Link href="/dashboard">Return to your dashboard</Link>
    </main>
  );
}

How to Use This Reference

Compare this file with your saved page. The component provides the complete denied-access message plus the dashboard link.

Protect every dynamic section

Each dynamic section needs one capability map. The route can use that map before it loads section data or renders protected content.

How the Existing Helpers Work

  • The existing requireSession() helper uses auth.api.getSession({ headers: await headers() }) to validate the signed-in account.
  • The existing requirePermission() helper sends the requested capability to auth.api.userHasPermission.
  • A missing session redirects to /login.
  • A denied capability redirects to /unauthorized.
  • Return to app/dashboard/[section]/page.tsx in the Explorer sidebar.
  • Replace everything above the existing const titleBySection declaration with the following security imports and capability map:
import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/authorization";
import {
  listBulletinsForUser,
  listStudentsForUser,
} from "@/lib/lms-data";

const permissionBySection: Record<string, Record<string, string[]>> = {
  enrollments: { student: ["manage"] },
  students: { student: ["read"] },
  lessons: { lesson: ["read"] },
  remarks: { remark: ["read"] },
  content: { content: ["read"] },
  bulletins: { bulletin: ["read"] },
};

What Does This Map Control?

  • The permissionBySection object assigns one required capability to every supported dashboard route.
  • The enrollments route requires permission to manage students.
  • The students route requires permission to read students.
  • The data imports prepare allowed routes to load their relevant records after authorization succeeds.
  • Save app/dashboard/[section]/page.tsx.
  • Check the existing development terminal for a successful compile.

The development server should finish compiling without a new error. That confirms the capability map uses valid imports and identifiers.

Seeing an Import Error?

  • Check that requirePermission is imported from @/lib/authorization.
  • Check that both list helpers are imported from @/lib/lms-data.
  • Confirm the filenames match the existing lib files exactly.

Help me fix the route imports.

The capability map becomes a security boundary when the route calls requirePermission() before rendering. Allowed requests also receive the authenticated account data needed by the existing query helpers.

  • Find export default async function SectionPage({ in app/dashboard/[section]/page.tsx.
  • Replace from that line through <h1>{titleBySection[section]}</h1> with the following server-protected component opening:
export default async function SectionPage({
  params,
}: {
  params: Promise<{ section: string }>;
}) {
  const { section } = await params;
  const permissions = permissionBySection[section];

  if (!permissions) {
    redirect("/dashboard");
  }

  const session = await requirePermission(permissions);
  const role = String(session.user.role ?? "pending").split(",")[0];

  const students =
    section === "students"
      ? await listStudentsForUser(session.user.email, role)
      : [];

  const bulletins =
    section === "bulletins"
      ? await listBulletinsForUser(session.user.email, role)
      : [];

  return (
    <section>
      <h1>{titleBySection[section]}</h1>

What Does This Server Check Do?

  • An unknown section redirects to /dashboard.
  • The requirePermission() call rejects a denied capability before section content renders.
  • The returned session supplies the authenticated email. It also supplies the account role.
  • The student or bulletin query runs only for its matching section.
  • Save app/dashboard/[section]/page.tsx.

Before you test the hidden route again, do you think the Teacher request reaches the Enrollments component?

  • Enter http://localhost:3000/dashboard/enrollments in the browser address bar.

The browser redirects to the Access denied page. That is the server boundary working before protected content can render.

Enrollments Still Opens?

  • Confirm the header showed Teacher before you entered the direct URL.
  • Check that const session = await requirePermission(permissions); appears before the component's return statement.
  • Confirm enrollments: { student: ["manage"] } appears in permissionBySection.

Why can my Teacher still open Enrollments?

Allowed sections can now render the records returned by their existing query helpers. The remaining sections show a simple confirmation after their capability check passes.

  • Find the temporary placeholder content below <h1>{titleBySection[section]}</h1>.
  • Replace that placeholder content with the following conditional section output:
      {section === "enrollments" ? (
        <p>Director-only enrollment workspace unlocked.</p>
      ) : null}

      {section === "students" ? (
        <ul>
          {students.map((student) => (
            <li key={String(student.studentId)}>
              {String(student.name)} | {String(student.gradeId)}
            </li>
          ))}
        </ul>
      ) : null}

      {section === "bulletins" ? (
        <ul>
          {bulletins.map((bulletin) => (
            <li key={String(bulletin.bulletinId)}>
              {String(bulletin.title)} | {String(bulletin.downloadLabel)}
            </li>
          ))}
        </ul>
      ) : null}

      {!["enrollments", "students", "bulletins"].includes(section) ? (
        <p>Your role has permission to open this section.</p>
      ) : null}

What Does This Output Show?

  • The Enrollments message can render only after the manage capability succeeds.
  • The Students section renders records returned by listStudentsForUser().
  • The Bulletins section renders records returned by listBulletinsForUser().
  • The remaining supported sections confirm that their read capability succeeded.
  • Save app/dashboard/[section]/page.tsx.

Before you check the Students route, do you think the same server helper blocks every Teacher page?

  • Enter http://localhost:3000/dashboard/students in the browser address bar.

The Students heading opens without the denied-access message. The Teacher's student read capability passed on the server.

Students Redirecting to Access Denied?

  • Confirm students: { student: ["read"] } appears in permissionBySection.
  • Confirm the teacher role in lib/permissions.ts includes student: ["read"].
  • Sign out from the dashboard before signing back in as teacher@noor.local.

Why is my Teacher blocked from Students?

✔️ Awesome, I've got everything!

Your dynamic route now checks section capabilities on the server before it renders protected content.

ⓧ I'd like to double check the full code

import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/authorization";
import {
  listBulletinsForUser,
  listStudentsForUser,
} from "@/lib/lms-data";

const permissionBySection: Record<string, Record<string, string[]>> = {
  enrollments: { student: ["manage"] },
  students: { student: ["read"] },
  lessons: { lesson: ["read"] },
  remarks: { remark: ["read"] },
  content: { content: ["read"] },
  bulletins: { bulletin: ["read"] },
};

const titleBySection: Record<string, string> = {
  enrollments: "Enrollment Management",
  students: "Students",
  lessons: "Lessons",
  remarks: "Remarks",
  content: "Content Library",
  bulletins: "Bulletins",
};

export default async function SectionPage({
  params,
}: {
  params: Promise<{ section: string }>;
}) {
  const { section } = await params;
  const permissions = permissionBySection[section];

  if (!permissions) {
    redirect("/dashboard");
  }

  const session = await requirePermission(permissions);
  const role = String(session.user.role ?? "pending").split(",")[0];

  const students =
    section === "students"
      ? await listStudentsForUser(session.user.email, role)
      : [];

  const bulletins =
    section === "bulletins"
      ? await listBulletinsForUser(session.user.email, role)
      : [];

  return (
    <section>
      <h1>{titleBySection[section]}</h1>

      {section === "enrollments" ? (
        <p>Director-only enrollment workspace unlocked.</p>
      ) : null}

      {section === "students" ? (
        <ul>
          {students.map((student) => (
            <li key={String(student.studentId)}>
              {String(student.name)} | {String(student.gradeId)}
            </li>
          ))}
        </ul>
      ) : null}

      {section === "bulletins" ? (
        <ul>
          {bulletins.map((bulletin) => (
            <li key={String(bulletin.bulletinId)}>
              {String(bulletin.title)} | {String(bulletin.downloadLabel)}
            </li>
          ))}
        </ul>
      ) : null}

      {!["enrollments", "students", "bulletins"].includes(section) ? (
        <p>Your role has permission to open this section.</p>
      ) : null}
    </section>
  );
}

How to Use This Reference

Compare the imports plus both maps with your file. Then compare the server check plus the conditional output.

  • Return to http://localhost:3000/dashboard/enrollments while signed in as the Teacher.

You should see Access denied again. A direct URL can no longer bypass the Teacher's capabilities.

You have closed the direct-route security gap. Next, you will narrow allowed data to each Teacher's assigned grades plus each Parent's linked children.

Filter Data by Relationships

Your Better Auth checks now stop a Teacher from opening Director-only sections. That closes the direct-route security gap from the previous step.

A role still describes a broad capability. This step uses relationship-scoped Mongoose queries to limit which students or bulletins each signed-in account receives.

In this step, get ready to:
  • Seed two students with one bulletin for each student.
  • Limit Teacher results to students from assigned grades.
  • Limit Parent results to bulletins for linked children.
Seed the relationship test data

The existing Director action creates the demo records in MongoDB Atlas. Its update-or-create behavior lets you run the action again without duplicating the records.

  • In the Noor Learning browser tab from earlier, click Sign out.
  • Enter admin@example.com in the Email field.
  • Enter the Director test password you chose earlier in the Password field.
  • Click Sign in.
  • Click Seed demo students and bulletins in the Demo Records section.

Good progress. You will return to the secure dashboard after the seed action updates the four demo records.

What did the seed action create?

  • The students collection now contains Amina Yusuf in grade-7.
  • The students collection now contains Yusuf Ali in grade-8.
  • The bulletins collection now contains one bulletin linked to student-amina.
  • The bulletins collection now contains one bulletin linked to student-yusuf.
Filter students by assigned grades

Why capabilities plus query filters?

The Teacher capability permits access to the Students section. The database query narrows that access to grades listed in the signed-in Teacher's AccessProfile.

These two layers enforce least privilege at both the page boundary and the record boundary.

  • In lib/lms-data.ts, find the existing listStudentsForUser() function.
  • Replace the full function with this code:
export async function listStudentsForUser(email: string, role: string) {
  await connectMongoose();

  if (role === "admin") {
    return Student.find({}).sort({ name: 1 }).lean();
  }

  const profile = await AccessProfile.findOne({
    email: email.toLowerCase(),
  }).lean();

  if (!profile) {
    return [];
  }

  if (role === "teacher") {
    return Student.find({
      gradeId: { $in: profile.assignedGradeIds },
    })
      .sort({ name: 1 })
      .lean();
  }

  return [];
}

How Does the Student Scope Work?

  • The admin branch returns every student for the Director view.
  • The profile lookup matches the authenticated email to an AccessProfile.
  • The Teacher query uses $in to match each student's gradeId against assignedGradeIds.
  • A missing profile or unsupported role receives an empty list.
  • Save lib/lms-data.ts.

Before you test the Teacher account, which students do you expect its assigned grade-7 relationship to reveal?

  • Return to the Noor Learning browser tab.
  • Click Sign out.
  • Enter teacher@noor.local in the Email field.
  • Enter the Teacher test password you chose earlier in the Password field.
  • Click Sign in.
  • Click Students in the dashboard navigation.

You will see only Amina Yusuf | grade-7. The Grade 8 student stays outside this Teacher's relationship scope.

Seeing the Wrong Student List?

  • Check that the Teacher profile uses teacher@noor.local as its email.
  • Check that its assignedGradeIds array contains grade-7.
  • Check that Amina's gradeId is also grade-7.

Help me debug the Teacher relationship filter.

Filter bulletins by linked children

A Parent's role permits bulletin access. The stored childIds relationship determines which bulletin records reach that Parent.

  • In lib/lms-data.ts, find the existing listBulletinsForUser() function.
  • Replace the full function with this code:
export async function listBulletinsForUser(email: string, role: string) {
  await connectMongoose();

  if (role === "admin") {
    return Bulletin.find({}).sort({ title: 1 }).lean();
  }

  const profile = await AccessProfile.findOne({
    email: email.toLowerCase(),
  }).lean();

  if (!profile || role !== "parent") {
    return [];
  }

  return Bulletin.find({
    studentId: { $in: profile.childIds },
  })
    .sort({ title: 1 })
    .lean();
}

How Does the Bulletin Scope Work?

  • The Director branch returns every bulletin.
  • The profile lookup finds relationships stored for the authenticated email.
  • The Parent guard returns an empty list for missing profiles or other roles.
  • The final query matches bulletin studentId values against the Parent's childIds array.
  • Save lib/lms-data.ts.

Before you test the Parent account, which bulletin should its student-amina relationship reveal?

  • Return to the Noor Learning browser tab.
  • Click Sign out.
  • Enter parent@noor.local in the Email field.
  • Enter the Parent test password you chose earlier in the Password field.
  • Click Sign in.
  • Click Bulletins in the dashboard navigation.

You will see only Amina Term 1 Bulletin | Download Amina's bulletin. The bulletin linked to Yusuf remains outside this Parent's relationship scope.

Seeing the Wrong Bulletin List?

  • Check that the Parent profile uses parent@noor.local as its email.
  • Check that its childIds array contains student-amina.
  • Check that Amina's bulletin uses student-amina as its studentId.

Help me debug the Parent relationship filter.

✔️ Awesome, I've got everything!

Your relationship data layer is complete. Double-check that you saved lib/lms-data.ts.

ⓧ I'd like to double check the full code

import mongoose from "mongoose";

const uri = process.env.MONGODB_URI;

if (!uri) {
  throw new Error("Please define the MONGODB_URI environment variable");
}

const globalForMongoose = globalThis as typeof globalThis & {
  noorMongoosePromise?: ReturnType<typeof mongoose.connect>;
};

async function connectMongoose() {
  globalForMongoose.noorMongoosePromise ??= mongoose.connect(uri, {
    dbName: "noor_lms",
  });
  return globalForMongoose.noorMongoosePromise;
}

const accessProfileSchema = new mongoose.Schema(
  {
    email: { type: String, required: true, unique: true, lowercase: true },
    role: {
      type: String,
      required: true,
      enum: ["teacher", "parent"],
    },
    field: { type: String, default: "" },
    assignedGradeIds: { type: [String], default: [] },
    childIds: { type: [String], default: [] },
  },
  { timestamps: true },
);

const studentSchema = new mongoose.Schema(
  {
    studentId: { type: String, required: true, unique: true },
    name: { type: String, required: true },
    gradeId: { type: String, required: true, index: true },
  },
  { timestamps: true },
);

const bulletinSchema = new mongoose.Schema(
  {
    bulletinId: { type: String, required: true, unique: true },
    studentId: { type: String, required: true, index: true },
    title: { type: String, required: true },
    downloadLabel: { type: String, required: true },
  },
  { timestamps: true },
);

const AccessProfile = mongoose.model(
  "AccessProfile",
  accessProfileSchema,
  "access_profiles",
  { overwriteModels: true },
);

const Student = mongoose.model("Student", studentSchema, "students", {
  overwriteModels: true,
});

const Bulletin = mongoose.model("Bulletin", bulletinSchema, "bulletins", {
  overwriteModels: true,
});

function cleanList(value: string) {
  return value
    .split(",")
    .map((item) => item.trim())
    .filter(Boolean);
}

export async function saveAccessProfile(input: {
  email: string;
  role: "teacher" | "parent";
  field: string;
  assignedGradeIds: string;
  childIds: string;
}) {
  await connectMongoose();
  await AccessProfile.findOneAndUpdate(
    { email: input.email.toLowerCase() },
    {
      email: input.email.toLowerCase(),
      role: input.role,
      field: input.field.trim(),
      assignedGradeIds: cleanList(input.assignedGradeIds),
      childIds: cleanList(input.childIds),
    },
    {
      upsert: true,
      returnDocument: "after",
      runValidators: true,
    },
  );
}

export async function seedDemoData() {
  await connectMongoose();

  await Student.findOneAndUpdate(
    { studentId: "student-amina" },
    { studentId: "student-amina", name: "Amina Yusuf", gradeId: "grade-7" },
    { upsert: true, returnDocument: "after", runValidators: true },
  );

  await Student.findOneAndUpdate(
    { studentId: "student-yusuf" },
    { studentId: "student-yusuf", name: "Yusuf Ali", gradeId: "grade-8" },
    { upsert: true, returnDocument: "after", runValidators: true },
  );

  await Bulletin.findOneAndUpdate(
    { bulletinId: "bulletin-amina-term-1" },
    {
      bulletinId: "bulletin-amina-term-1",
      studentId: "student-amina",
      title: "Amina Term 1 Bulletin",
      downloadLabel: "Download Amina's bulletin",
    },
    { upsert: true, returnDocument: "after", runValidators: true },
  );

  await Bulletin.findOneAndUpdate(
    { bulletinId: "bulletin-yusuf-term-1" },
    {
      bulletinId: "bulletin-yusuf-term-1",
      studentId: "student-yusuf",
      title: "Yusuf Term 1 Bulletin",
      downloadLabel: "Download Yusuf's bulletin",
    },
    { upsert: true, returnDocument: "after", runValidators: true },
  );
}

export async function listStudentsForUser(email: string, role: string) {
  await connectMongoose();

  if (role === "admin") {
    return Student.find({}).sort({ name: 1 }).lean();
  }

  const profile = await AccessProfile.findOne({
    email: email.toLowerCase(),
  }).lean();

  if (!profile) {
    return [];
  }

  if (role === "teacher") {
    return Student.find({
      gradeId: { $in: profile.assignedGradeIds },
    })
      .sort({ name: 1 })
      .lean();
  }

  return [];
}

export async function listBulletinsForUser(email: string, role: string) {
  await connectMongoose();

  if (role === "admin") {
    return Bulletin.find({}).sort({ title: 1 }).lean();
  }

  const profile = await AccessProfile.findOne({
    email: email.toLowerCase(),
  }).lean();

  if (!profile || role !== "parent") {
    return [];
  }

  return Bulletin.find({
    studentId: { $in: profile.childIds },
  })
    .sort({ title: 1 })
    .lean();
}

Your authorization layer now controls both permitted actions and permitted records. Noor Learning can protect data even when two accounts share the same role.

Secret mission

Prove Two Teachers Cannot Cross Grade Boundaries

Create a second Teacher account for Grade 8. Compare the student records available to both Teachers. Try a URL change that tests whether either account can cross its stored grade boundary.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep Noor Learning available for more testing, pause the local development server, or remove the project data. This project creates no new paid service, but your MongoDB Atlas usage remains subject to your existing plan.

Cost warning

The authentication records, access profiles, students, and bulletins remain in your existing Atlas deployment until you remove them. Keeping them creates no new project charge, but their storage remains part of your current Atlas plan.

Resources you used:

  • The local Noor Learning development server.
  • The Better Auth user records in noor_lms for the Director, Teacher, Parent, and second Teacher accounts.
  • The Better Auth account records associated with those four users.
  • The Better Auth session records associated with those four users.
  • The access_profiles records for teacher@noor.local, parent@noor.local, and teacher2@noor.local.
  • The students demo records identified by student-amina and student-yusuf.
  • The bulletins demo records identified by bulletin-amina-term-1 and bulletin-yusuf-term-1.

Keep everything running

No action is needed. Choose this if you are still testing role permissions or relationship filters.

  • Leave the local development server running while you continue testing Noor Learning.
  • Retain the project records in noor_lms for future role-isolation checks.
  • Review your existing Atlas plan if you need to confirm how retained data affects your current usage.

Pause - I'll come back to this later

Stop the local development server to free your Mac's resources. Your Atlas records remain available for the next session.

  • Return to the Terminal window where the Noor Learning development server is running.
  • Press Ctrl+C to stop the server.
  • Retain the authentication records and demo data in noor_lms.

You'll see the development server stop producing request logs. The Noor Learning data remains stored in Atlas.

Delete - I don't want to use this again

Deleting noor_lms is permanent. Take a moment to check whether it contains unrelated Noor Learning data before removing the entire database.

  • Return to the Terminal window where the Noor Learning development server is running.
  • Press Ctrl+C to stop the server.

Database-level cleanup suits a database dedicated entirely to this project. It removes every project record in one operation.

  • Select noor_lms as the deletion target in your normal Atlas administration workflow.
  • Complete the database deletion only after confirming that noor_lms contains no unrelated LMS data.
  • Confirm that noor_lms is no longer listed in your Atlas deployment.

Record-level cleanup preserves the database and any unrelated LMS data. Use this path when noor_lms contains records you need to keep.

  • Remove Better Auth user records associated with admin@example.com, teacher@noor.local, parent@noor.local, and teacher2@noor.local.
  • Remove Better Auth account records associated with those four email addresses.
  • Remove Better Auth session records associated with those four email addresses.
  • Remove the access_profiles records for teacher@noor.local, parent@noor.local, and teacher2@noor.local.
  • Remove the students records identified by student-amina and student-yusuf.
  • Remove the bulletins records identified by bulletin-amina-term-1 and bulletin-yusuf-term-1.
  • Confirm that searches for the four test account emails return no Better Auth records.
  • Confirm that access_profiles contains none of the three project access profiles.
  • Confirm that students contains neither demo student record.
  • Confirm that bulletins contains neither demo bulletin record.

Nice Work!

Nice Work!

Outstanding work! You built a server-enforced access layer that limits every Noor Learning account to its permitted sections and records.

You've learned how to:

  • Authenticate Director, Teacher, and Parent accounts through Better Auth email and password sign-in. Store each session in MongoDB Atlas.
  • Build role-aware navigation from a role-based access control model for Director, Teacher, Parent, and Pending accounts. Enforce the same capabilities through server-side authorization so a direct request to a forbidden section redirects to /unauthorized.
  • Apply relationship-scoped queries with Mongoose so Teachers receive students from their assigned grades. Filter Parent bulletins through each account's linked children.
  • Secret Mission: Verify that teacher@noor.local sees only Amina Yusuf | grade-7. Confirm that teacher2@noor.local sees only Yusuf Ali | grade-8 despite sharing the same Teacher role.

Ready to quiz yourself? Start the quiz below.